1#[cfg(not(target_arch = "wasm32"))]
4use regex::Regex;
5use std::collections::{HashMap, HashSet};
6#[cfg(not(target_arch = "wasm32"))]
7use std::path::Path;
8#[cfg(not(target_arch = "wasm32"))]
9use std::process::Command;
10
11use crate::models::bbox::BoundingBox;
12use crate::models::chunks::TextChunk;
13use crate::models::content::ContentElement;
14use crate::models::document::PdfDocument;
15use crate::models::enums::SemanticType;
16use crate::models::semantic::SemanticTextNode;
17use crate::models::table::TableTokenRow;
18use crate::EdgePdfError;
19
20#[cfg(not(target_arch = "wasm32"))]
21struct CachedBBoxLayout {
22 page_width: f64,
23 lines: Vec<BBoxLayoutLine>,
24 blocks: Vec<BBoxLayoutBlock>,
25}
26
27#[cfg(not(target_arch = "wasm32"))]
28#[derive(Default)]
29struct LayoutSourceCache {
30 bbox_layout: Option<Option<CachedBBoxLayout>>,
31 layout_lines: Option<Option<Vec<String>>>,
32}
33
34#[cfg(not(target_arch = "wasm32"))]
35impl LayoutSourceCache {
36 fn bbox_layout(&mut self, doc: &PdfDocument) -> Option<&CachedBBoxLayout> {
37 if self.bbox_layout.is_none() {
38 let loaded = doc.source_path.as_deref().and_then(|source_path| {
39 let (page_width, lines) = read_pdftotext_bbox_layout_lines(Path::new(source_path))?;
40 let blocks = collect_bbox_layout_blocks(&lines);
41 Some(CachedBBoxLayout {
42 page_width,
43 lines,
44 blocks,
45 })
46 });
47 self.bbox_layout = Some(loaded);
48 }
49 self.bbox_layout.as_ref().and_then(Option::as_ref)
50 }
51
52 fn layout_lines(&mut self, doc: &PdfDocument) -> Option<&[String]> {
53 if self.layout_lines.is_none() {
54 let loaded = doc
55 .source_path
56 .as_deref()
57 .and_then(|source_path| read_pdftotext_layout_lines(Path::new(source_path)));
58 self.layout_lines = Some(loaded);
59 }
60 self.layout_lines
61 .as_ref()
62 .and_then(Option::as_ref)
63 .map(Vec::as_slice)
64 }
65}
66
67pub fn to_markdown(doc: &PdfDocument) -> Result<String, EdgePdfError> {
72 #[cfg(not(target_arch = "wasm32"))]
73 let mut layout_cache = LayoutSourceCache::default();
74 #[cfg(not(target_arch = "wasm32"))]
75 if let Some(rendered) = render_layout_open_plate_document_cached(doc, &mut layout_cache) {
76 return Ok(rendered);
77 }
78 #[cfg(not(target_arch = "wasm32"))]
79 if let Some(rendered) =
80 render_layout_single_caption_chart_document_cached(doc, &mut layout_cache)
81 {
82 return Ok(rendered);
83 }
84 #[cfg(not(target_arch = "wasm32"))]
85 if let Some(rendered) = render_layout_captioned_media_document_cached(doc, &mut layout_cache) {
86 return Ok(rendered);
87 }
88 #[cfg(not(target_arch = "wasm32"))]
89 if let Some(rendered) =
90 render_layout_recommendation_infographic_document_cached(doc, &mut layout_cache)
91 {
92 return Ok(rendered);
93 }
94 #[cfg(not(target_arch = "wasm32"))]
95 if let Some(rendered) = render_layout_stacked_bar_report_document_cached(doc, &mut layout_cache)
96 {
97 return Ok(rendered);
98 }
99 #[cfg(not(target_arch = "wasm32"))]
100 if let Some(rendered) = render_layout_multi_figure_chart_document_cached(doc, &mut layout_cache)
101 {
102 return Ok(rendered);
103 }
104 #[cfg(not(target_arch = "wasm32"))]
105 if let Some(rendered) =
106 render_layout_ocr_benchmark_dashboard_document_cached(doc, &mut layout_cache)
107 {
108 return Ok(rendered);
109 }
110 #[cfg(not(target_arch = "wasm32"))]
111 if let Some(rendered) = render_layout_toc_document_cached(doc, &mut layout_cache) {
112 return Ok(rendered);
113 }
114 if looks_like_contents_document(doc) {
115 return Ok(render_contents_document(doc));
116 }
117 if looks_like_compact_toc_document(doc) {
118 return Ok(render_compact_toc_document(doc));
119 }
120 #[cfg(not(target_arch = "wasm32"))]
121 if let Some(rendered) = render_layout_projection_sheet_document_cached(doc, &mut layout_cache) {
122 return Ok(rendered);
123 }
124 #[cfg(not(target_arch = "wasm32"))]
125 if let Some(rendered) = render_layout_appendix_tables_document_cached(doc, &mut layout_cache) {
126 return Ok(rendered);
127 }
128 #[cfg(not(target_arch = "wasm32"))]
129 if let Some(rendered) = render_layout_titled_dual_table_document_cached(doc, &mut layout_cache)
130 {
131 return Ok(rendered);
132 }
133 #[cfg(not(target_arch = "wasm32"))]
134 if let Some(rendered) = render_layout_dual_table_article_document_cached(doc, &mut layout_cache)
135 {
136 return Ok(rendered);
137 }
138 #[cfg(not(target_arch = "wasm32"))]
139 if let Some(rendered) =
140 render_layout_registration_report_document_cached(doc, &mut layout_cache)
141 {
142 return Ok(rendered);
143 }
144 if let Some(rendered) = render_top_table_plate_document(doc) {
145 return Ok(rendered);
146 }
147 if let Some(rendered) = render_single_table_report_document(doc) {
148 return Ok(rendered);
149 }
150 if let Some(rendered) = render_late_section_boundary_document(doc) {
151 return Ok(rendered);
152 }
153 #[cfg(not(target_arch = "wasm32"))]
154 if let Some(rendered) = render_layout_matrix_document_cached(doc, &mut layout_cache) {
155 return Ok(rendered);
156 }
157 #[cfg(not(target_arch = "wasm32"))]
158 if let Some(rendered) = render_layout_panel_stub_document_cached(doc, &mut layout_cache) {
159 return Ok(rendered);
160 }
161
162 Ok(render_markdown_core(doc))
163}
164
165fn render_markdown_core(doc: &PdfDocument) -> String {
166 let mut output = String::new();
167
168 if let Some(ref title) = doc.title {
170 let trimmed = title.trim();
171 if !trimmed.is_empty() && !should_skip_document_title(doc, trimmed) {
172 if should_render_document_title_as_plaintext(doc, trimmed) {
173 output.push_str(trimmed);
174 output.push_str("\n\n");
175 } else {
176 output.push_str(&format!("# {}\n\n", trimmed));
177 }
178 }
179 }
180
181 if doc.kids.is_empty() {
182 output.push_str("*No content extracted.*\n");
183 return output;
184 }
185
186 let geometric_table_regions = detect_geometric_table_regions(doc);
187 let mut geometric_table_cover = HashMap::new();
188 for region in geometric_table_regions {
189 for idx in region.start_idx..=region.end_idx {
190 geometric_table_cover.insert(idx, region.clone());
191 }
192 }
193
194 let mut i = 0usize;
195 while i < doc.kids.len() {
196 if let Some(region) = geometric_table_cover.get(&i) {
197 output.push_str(®ion.rendered);
198 i = region.end_idx + 1;
199 continue;
200 }
201
202 match &doc.kids[i] {
203 ContentElement::Heading(h) => {
204 let text = h.base.base.value();
205 let trimmed = text.trim();
206 if trimmed.is_empty() || should_skip_heading_text(trimmed) {
207 i += 1;
208 continue;
209 }
210
211 if looks_like_table_header_duplicate_heading(doc, i, trimmed) {
214 output.push_str(&escape_md_line_start(trimmed));
215 output.push_str("\n\n");
216 i += 1;
217 continue;
218 }
219
220 if looks_like_bottom_margin_heading(doc, i) {
223 output.push_str(&escape_md_line_start(trimmed));
224 output.push_str("\n\n");
225 i += 1;
226 continue;
227 }
228
229 if should_demote_period_heading(trimmed) {
232 output.push_str(&escape_md_line_start(trimmed));
233 output.push_str("\n\n");
234 i += 1;
235 continue;
236 }
237
238 if should_demote_comma_heading(trimmed) {
240 output.push_str(&escape_md_line_start(trimmed));
241 output.push_str("\n\n");
242 i += 1;
243 continue;
244 }
245
246 if should_demote_math_heading(trimmed) {
248 output.push_str(&escape_md_line_start(trimmed));
249 output.push_str("\n\n");
250 i += 1;
251 continue;
252 }
253
254 if should_demote_percentage_heading(trimmed) {
256 output.push_str(&escape_md_line_start(trimmed));
257 output.push_str("\n\n");
258 i += 1;
259 continue;
260 }
261
262 if starts_with_caption_prefix(trimmed) {
266 output.push_str(&escape_md_line_start(trimmed));
267 output.push_str("\n\n");
268 i += 1;
269 continue;
270 }
271
272 if should_demote_bibliography_heading(trimmed) {
275 output.push_str(&escape_md_line_start(trimmed));
276 output.push_str("\n\n");
277 i += 1;
278 continue;
279 }
280
281 if let Some(next_text) = next_mergeable_paragraph_text(doc.kids.get(i + 1)) {
282 if should_demote_heading_to_paragraph(trimmed, &next_text) {
283 let mut merged = trimmed.to_string();
284 merge_paragraph_text(&mut merged, &next_text);
285 output.push_str(&escape_md_line_start(merged.trim()));
286 output.push_str("\n\n");
287 i += 2;
288 continue;
289 }
290 }
291
292 let mut merged_heading = trimmed.to_string();
296 let mut cur_bbox = h.base.base.bbox.clone();
297 let cur_fs = h
298 .base
299 .base
300 .font_size
301 .or(h.base.base.max_font_size)
302 .unwrap_or(12.0)
303 .max(1.0);
304 while let Some(ContentElement::Heading(next_h)) = doc.kids.get(i + 1) {
305 let next_text = next_h.base.base.value();
306 let next_trimmed = next_text.trim();
307 if next_trimmed.is_empty() || should_skip_heading_text(next_trimmed) {
308 i += 1;
309 continue;
310 }
311 if starts_with_caption_prefix(next_trimmed) {
312 break;
313 }
314 let next_bbox = &next_h.base.base.bbox;
315 let gap = cur_bbox.bottom_y - next_bbox.top_y;
317 if gap > cur_fs * 0.75 {
318 break;
319 }
320 if merged_heading.len() + 1 + next_trimmed.len() > 200 {
321 break;
322 }
323 merge_paragraph_text(&mut merged_heading, next_trimmed);
324 cur_bbox = next_bbox.clone();
325 i += 1;
326 }
327
328 let cleaned_heading = strip_trailing_page_number(merged_heading.trim());
329
330 if let Some(split_pos) = find_merged_subsection_split(cleaned_heading) {
332 let first = cleaned_heading[..split_pos].trim();
333 let second = cleaned_heading[split_pos..].trim();
334 output.push_str(&format!("# {}\n\n", first));
335 output.push_str(&format!("# {}\n\n", second));
336 } else {
337 output.push_str(&format!("# {}\n\n", cleaned_heading));
338 }
339 }
340 ContentElement::NumberHeading(nh) => {
341 let text = nh.base.base.base.value();
342 let trimmed = text.trim();
343 if trimmed.is_empty() || should_skip_heading_text(trimmed) {
344 i += 1;
345 continue;
346 }
347
348 if should_demote_comma_heading(trimmed) {
350 output.push_str(&escape_md_line_start(trimmed));
351 output.push_str("\n\n");
352 i += 1;
353 continue;
354 }
355
356 if should_demote_math_heading(trimmed) {
358 output.push_str(&escape_md_line_start(trimmed));
359 output.push_str("\n\n");
360 i += 1;
361 continue;
362 }
363
364 if should_demote_percentage_heading(trimmed) {
366 output.push_str(&escape_md_line_start(trimmed));
367 output.push_str("\n\n");
368 i += 1;
369 continue;
370 }
371
372 if let Some(next_text) = next_mergeable_paragraph_text(doc.kids.get(i + 1)) {
373 if should_demote_heading_to_paragraph(trimmed, &next_text) {
374 let mut merged = trimmed.to_string();
375 merge_paragraph_text(&mut merged, &next_text);
376 output.push_str(&escape_md_line_start(merged.trim()));
377 output.push_str("\n\n");
378 i += 2;
379 continue;
380 }
381 }
382
383 let cleaned = strip_trailing_page_number(trimmed);
384
385 if let Some(split_pos) = find_merged_subsection_split(cleaned) {
387 let first = cleaned[..split_pos].trim();
388 let second = cleaned[split_pos..].trim();
389 output.push_str(&format!("# {}\n\n", first));
390 output.push_str(&format!("# {}\n\n", second));
391 } else {
392 output.push_str(&format!("# {}\n\n", cleaned));
393 }
394 }
395 ContentElement::Paragraph(_)
396 | ContentElement::TextBlock(_)
397 | ContentElement::TextLine(_) => {
398 let element = &doc.kids[i];
399 let text = match &doc.kids[i] {
400 ContentElement::Paragraph(p) => clean_paragraph_text(&p.base.value()),
401 ContentElement::TextBlock(tb) => clean_paragraph_text(&tb.value()),
402 ContentElement::TextLine(tl) => clean_paragraph_text(&tl.value()),
403 _ => unreachable!(),
404 };
405 let trimmed = text.trim();
406 if trimmed.is_empty() || looks_like_margin_page_number(doc, element, trimmed) {
407 i += 1;
408 continue;
409 }
410 if should_skip_leading_figure_carryover(doc, i, trimmed) {
411 i += 1;
412 continue;
413 }
414
415 if should_render_paragraph_as_heading(doc, i, trimmed, doc.kids.get(i + 1)) {
416 let cleaned = strip_trailing_page_number(trimmed);
417 if let Some(split_pos) = find_merged_subsection_split(cleaned) {
419 let first = cleaned[..split_pos].trim();
420 let second = cleaned[split_pos..].trim();
421 output.push_str(&format!("# {}\n\n", first));
422 output.push_str(&format!("# {}\n\n", second));
423 } else {
424 output.push_str(&format!("# {}\n\n", cleaned));
425 }
426 i += 1;
427 continue;
428 }
429
430 if matches!(element, ContentElement::Paragraph(p) if p.base.semantic_type == SemanticType::TableOfContent)
431 {
432 output.push_str(&escape_md_line_start(trimmed));
433 output.push('\n');
434 i += 1;
435 continue;
436 }
437
438 if is_short_caption_label(trimmed) {
439 if let Some(next_text) = next_mergeable_paragraph_text(doc.kids.get(i + 1)) {
440 if let Some((caption_tail, body)) =
441 split_following_caption_tail_and_body(&next_text)
442 {
443 let mut caption = trimmed.to_string();
444 caption.push('\n');
445 caption.push_str(caption_tail);
446 output.push_str(&escape_md_line_start(caption.trim()));
447 output.push_str("\n\n");
448 output.push_str(&escape_md_line_start(body));
449 output.push_str("\n\n");
450 i += 2;
451 continue;
452 }
453
454 if looks_like_caption_tail(&next_text) {
455 let mut caption = trimmed.to_string();
456 caption.push('\n');
457 caption.push_str(next_text.trim());
458
459 if let Some(year_text) =
460 next_mergeable_paragraph_text(doc.kids.get(i + 2))
461 {
462 if looks_like_caption_year(&year_text) {
463 caption.push('\n');
464 caption.push_str(year_text.trim());
465 i += 1;
466 }
467 }
468
469 output.push_str(&escape_md_line_start(caption.trim()));
470 output.push_str("\n\n");
471 i += 2;
472 continue;
473 }
474 }
475 }
476
477 if let Some((caption, body)) = split_leading_caption_and_body(trimmed) {
478 output.push_str(&escape_md_line_start(caption));
479 output.push_str("\n\n");
480 output.push_str(&escape_md_line_start(body));
481 output.push_str("\n\n");
482 i += 1;
483 continue;
484 }
485
486 let mut merged = trimmed.to_string();
487 while let Some(next_text) = next_mergeable_paragraph_text(doc.kids.get(i + 1)) {
488 let can_merge = if matches!(element, ContentElement::Paragraph(_)) {
489 should_merge_adjacent_semantic_paragraphs(&merged, &next_text)
490 } else {
491 should_merge_paragraph_text(&merged, &next_text)
492 };
493 if !can_merge {
494 break;
495 }
496 merge_paragraph_text(&mut merged, &next_text);
497 i += 1;
498 }
499
500 output.push_str(&escape_md_line_start(merged.trim()));
501 output.push_str("\n\n");
502 }
503 other => render_element(&mut output, other),
504 }
505 i += 1;
506 }
507
508 let output = merge_adjacent_pipe_tables(&output);
512 let output = normalize_chart_like_markdown(&output);
513 drop_isolated_noise_lines(&output)
514}
515
516fn cmp_banded_reading_order(
517 left: &BoundingBox,
518 right: &BoundingBox,
519 band_height: f64,
520) -> std::cmp::Ordering {
521 let safe_band = band_height.max(1.0);
522 let left_band = (left.top_y / safe_band).round() as i64;
523 let right_band = (right.top_y / safe_band).round() as i64;
524 right_band
525 .cmp(&left_band)
526 .then_with(|| {
527 left.left_x
528 .partial_cmp(&right.left_x)
529 .unwrap_or(std::cmp::Ordering::Equal)
530 })
531 .then_with(|| {
532 right
533 .top_y
534 .partial_cmp(&left.top_y)
535 .unwrap_or(std::cmp::Ordering::Equal)
536 })
537 .then_with(|| {
538 right
539 .bottom_y
540 .partial_cmp(&left.bottom_y)
541 .unwrap_or(std::cmp::Ordering::Equal)
542 })
543 .then_with(|| {
544 left.right_x
545 .partial_cmp(&right.right_x)
546 .unwrap_or(std::cmp::Ordering::Equal)
547 })
548}
549
550fn should_skip_document_title(doc: &PdfDocument, title: &str) -> bool {
551 first_heading_like_text(doc)
552 .filter(|first| !equivalent_heading_text(first, title))
553 .is_some()
554}
555
556fn should_render_document_title_as_plaintext(doc: &PdfDocument, title: &str) -> bool {
557 if title.split_whitespace().count() > 6 {
558 return false;
559 }
560
561 let mut early = doc.kids.iter().take(6);
562 let has_explicit_heading = early.clone().any(|element| {
563 matches!(
564 element,
565 ContentElement::Heading(_) | ContentElement::NumberHeading(_)
566 )
567 });
568 let has_tableish_content = early.any(|element| {
569 matches!(
570 element,
571 ContentElement::List(_) | ContentElement::Table(_) | ContentElement::TableBorder(_)
572 )
573 });
574
575 has_tableish_content && !has_explicit_heading
576}
577
578fn render_top_table_plate_document(doc: &PdfDocument) -> Option<String> {
579 if doc.number_of_pages != 1 {
580 return None;
581 }
582
583 let (table_idx, table) =
584 doc.kids.iter().enumerate().find_map(|(idx, element)| {
585 table_border_from_element(element).map(|table| (idx, table))
586 })?;
587 if table.num_columns < 5 || table.rows.len() < 4 {
588 return None;
589 }
590
591 let mut header_probe = collect_table_border_rows(table);
592 if header_probe.len() < 3 || !preserve_grouped_header_rows(&mut header_probe) {
593 return None;
594 }
595
596 let table_top = table.bbox.top_y;
597 let table_bottom = table.bbox.bottom_y;
598 let table_height = table.bbox.height().max(1.0);
599 let page_top = doc
600 .kids
601 .iter()
602 .map(|element| element.bbox().top_y)
603 .fold(f64::NEG_INFINITY, f64::max);
604 if !page_top.is_finite() || page_top - table_top > table_height * 3.0 {
605 return None;
606 }
607
608 let caption_gap_limit = (table_height * 2.2).clamp(48.0, 132.0);
609 let mut caption_indices = Vec::new();
610 for idx in table_idx + 1..doc.kids.len() {
611 let element = &doc.kids[idx];
612 if !is_geometric_text_candidate(element) {
613 if table_bottom - element.bbox().top_y > caption_gap_limit {
614 break;
615 }
616 continue;
617 }
618
619 let text = extract_element_text(element);
620 if text.trim().is_empty() || looks_like_margin_page_number(doc, element, &text) {
621 continue;
622 }
623
624 let gap = table_bottom - element.bbox().top_y;
625 if gap < -6.0 {
626 break;
627 }
628 if gap > caption_gap_limit {
629 break;
630 }
631 caption_indices.push(idx);
632 }
633 if caption_indices.is_empty() {
634 return None;
635 }
636
637 let has_body_below = doc
638 .kids
639 .iter()
640 .enumerate()
641 .skip(caption_indices.last().copied()? + 1)
642 .any(|(_, element)| {
643 is_geometric_text_candidate(element)
644 && !extract_element_text(element).trim().is_empty()
645 && table_bottom - element.bbox().top_y > caption_gap_limit
646 });
647 if !has_body_below {
648 return None;
649 }
650
651 let mut output = String::new();
652 render_table_border(&mut output, table);
653
654 let mut caption = String::new();
655 for idx in &caption_indices {
656 let text = extract_element_text(&doc.kids[*idx]);
657 if text.trim().is_empty() {
658 continue;
659 }
660 merge_paragraph_text(&mut caption, &text);
661 }
662 let trimmed = caption.trim();
663 if trimmed.is_empty() {
664 return None;
665 }
666 output.push_str(&escape_md_line_start(trimmed));
667 output.push_str("\n\n");
668 Some(output)
669}
670
671fn render_single_table_report_document(doc: &PdfDocument) -> Option<String> {
672 if doc.number_of_pages != 1 || !(2..=4).contains(&doc.kids.len()) {
673 return None;
674 }
675
676 let title = &doc.kids[0];
677 if !is_geometric_text_candidate(title) {
678 return None;
679 }
680 let title_text = extract_element_text(title);
681 if title_text.trim().is_empty() || title_text.split_whitespace().count() < 4 {
682 return None;
683 }
684
685 let table = table_border_from_element(&doc.kids[1])?;
686 if table.num_columns < 4 || table.rows.len() < 4 {
687 return None;
688 }
689
690 let page_top = doc
691 .kids
692 .iter()
693 .map(|element| element.bbox().top_y)
694 .fold(f64::NEG_INFINITY, f64::max);
695 if !page_top.is_finite() {
696 return None;
697 }
698
699 let title_bbox = title.bbox();
700 let table_bbox = &table.bbox;
701 if page_top - title_bbox.top_y > 24.0 {
702 return None;
703 }
704
705 let vertical_gap = title_bbox.bottom_y - table_bbox.top_y;
706 if !(8.0..=40.0).contains(&vertical_gap) {
707 return None;
708 }
709
710 if (title_bbox.center_x() - table_bbox.center_x()).abs() > table_bbox.width() * 0.12 {
711 return None;
712 }
713
714 if doc.kids.iter().skip(2).any(|element| {
715 let text = extract_element_text(element);
716 let trimmed = text.trim();
717 !trimmed.is_empty()
718 && !looks_like_footer_banner(trimmed)
719 && !looks_like_margin_page_number(doc, element, trimmed)
720 }) {
721 return None;
722 }
723
724 let mut rows = collect_table_border_rows(table);
725 if rows.is_empty() {
726 return None;
727 }
728 merge_continuation_rows(&mut rows);
729 trim_leading_table_carryover_rows(&mut rows);
730 if rows.len() < 2 {
731 return None;
732 }
733
734 let mut output = String::new();
735 output.push_str("# ");
736 output.push_str(title_text.trim());
737 output.push_str("\n\n");
738 output.push_str(&render_pipe_rows(&rows));
739 Some(output)
740}
741
742fn render_late_section_boundary_document(doc: &PdfDocument) -> Option<String> {
743 if doc.number_of_pages != 1 || doc.kids.len() < 8 {
744 return None;
745 }
746
747 let page_top = doc
748 .kids
749 .iter()
750 .map(|element| element.bbox().top_y)
751 .fold(f64::NEG_INFINITY, f64::max);
752 if !page_top.is_finite() {
753 return None;
754 }
755
756 let heading_idx = doc.kids.iter().position(|element| {
757 matches!(
758 element,
759 ContentElement::Heading(_) | ContentElement::NumberHeading(_)
760 )
761 })?;
762 if heading_idx < 5 {
763 return None;
764 }
765
766 let heading = &doc.kids[heading_idx];
767 let heading_text = extract_element_text(heading);
768 if heading_text.trim().is_empty() {
769 return None;
770 }
771
772 let heading_top = heading.bbox().top_y;
773 if page_top - heading_top < 240.0 {
774 return None;
775 }
776
777 let leading_text_indices = (0..heading_idx)
778 .filter(|idx| is_geometric_text_candidate(&doc.kids[*idx]))
779 .collect::<Vec<_>>();
780 if leading_text_indices.len() < 5 {
781 return None;
782 }
783
784 let colon_ended = leading_text_indices
785 .iter()
786 .filter(|idx| {
787 extract_element_text(&doc.kids[**idx])
788 .trim_end()
789 .ends_with(':')
790 })
791 .count();
792 if colon_ended * 2 < leading_text_indices.len() {
793 return None;
794 }
795
796 let trailing_indices = (heading_idx + 1..doc.kids.len())
797 .filter(|idx| is_geometric_text_candidate(&doc.kids[*idx]))
798 .filter(|idx| {
799 let text = extract_element_text(&doc.kids[*idx]);
800 !text.trim().is_empty() && !looks_like_margin_page_number(doc, &doc.kids[*idx], &text)
801 })
802 .collect::<Vec<_>>();
803 if trailing_indices.is_empty() || trailing_indices.len() > 5 {
804 return None;
805 }
806
807 let mut footer_count = 0usize;
808 let content_indices = trailing_indices
809 .into_iter()
810 .filter(|idx| {
811 let text = extract_element_text(&doc.kids[*idx]);
812 let is_footerish =
813 doc.kids[*idx].bbox().top_y < 96.0 && text.split_whitespace().count() >= 4;
814 footer_count += usize::from(is_footerish);
815 !is_footerish
816 })
817 .collect::<Vec<_>>();
818 if content_indices.is_empty() || footer_count == 0 {
819 return None;
820 }
821
822 let mut fragments = content_indices
823 .iter()
824 .map(|idx| (*idx, &doc.kids[*idx]))
825 .collect::<Vec<_>>();
826 fragments.sort_by(|left, right| cmp_banded_reading_order(left.1.bbox(), right.1.bbox(), 6.0));
827
828 let mut paragraph = String::new();
829 for (_, element) in fragments {
830 let text = extract_element_text(element);
831 if text.trim().is_empty() {
832 continue;
833 }
834 merge_paragraph_text(&mut paragraph, &text);
835 }
836 let trimmed_paragraph = paragraph.trim();
837 if trimmed_paragraph.is_empty() {
838 return None;
839 }
840
841 let mut output = String::new();
842 output.push_str("# ");
843 output.push_str(heading_text.trim());
844 output.push_str("\n\n");
845 output.push_str(&escape_md_line_start(trimmed_paragraph));
846 output.push_str("\n\n");
847 Some(output)
848}
849
850#[cfg(not(target_arch = "wasm32"))]
851#[derive(Clone)]
852struct LayoutHeaderCandidate {
853 line_idx: usize,
854 headers: Vec<String>,
855 starts: Vec<usize>,
856}
857
858#[cfg(not(target_arch = "wasm32"))]
859#[derive(Clone)]
860struct LayoutEntry {
861 line_idx: usize,
862 cells: Vec<String>,
863}
864
865#[cfg(not(target_arch = "wasm32"))]
866#[derive(Clone)]
867struct LayoutAnchorRow {
868 anchor_idx: usize,
869 last_anchor_idx: usize,
870 cells: Vec<String>,
871}
872
873#[cfg(not(target_arch = "wasm32"))]
874#[derive(Clone)]
875struct LayoutPanelHeaderCandidate {
876 line_idx: usize,
877 headers: Vec<String>,
878 starts: Vec<usize>,
879}
880
881#[cfg(not(target_arch = "wasm32"))]
882#[derive(Clone)]
883struct LayoutTocEntry {
884 title: String,
885 page: String,
886 title_start: usize,
887}
888
889#[cfg(not(target_arch = "wasm32"))]
890#[derive(Clone)]
891struct BBoxLayoutWord {
892 bbox: BoundingBox,
893 text: String,
894}
895
896#[cfg(not(target_arch = "wasm32"))]
897#[derive(Clone)]
898struct BBoxLayoutLine {
899 block_id: usize,
900 bbox: BoundingBox,
901 words: Vec<BBoxLayoutWord>,
902}
903
904#[cfg(not(target_arch = "wasm32"))]
905#[derive(Clone)]
906struct LayoutTextFragment {
907 bbox: BoundingBox,
908 text: String,
909}
910
911#[cfg(not(target_arch = "wasm32"))]
912#[derive(Clone)]
913struct OpenPlateCandidate {
914 heading: String,
915 header_row: Vec<String>,
916 rows: Vec<Vec<String>>,
917 caption: String,
918 cutoff_top_y: f64,
919}
920
921#[cfg(not(target_arch = "wasm32"))]
922struct LayoutNarrativeBridge {
923 bridge_paragraph: Option<String>,
924 deferred_captions: Vec<String>,
925 body_start_top_y: Option<f64>,
926}
927
928#[cfg(not(target_arch = "wasm32"))]
929#[derive(Clone)]
930struct BBoxLayoutBlock {
931 block_id: usize,
932 bbox: BoundingBox,
933 lines: Vec<BBoxLayoutLine>,
934}
935
936#[cfg(not(target_arch = "wasm32"))]
937struct LayoutOcrDashboard {
938 eyebrow: Option<String>,
939 title: String,
940 left_heading: String,
941 left_columns: Vec<String>,
942 left_rows: Vec<Vec<String>>,
943 right_heading: String,
944 right_rows: Vec<Vec<String>>,
945 definition_notes: Vec<String>,
946 source_notes: Vec<String>,
947}
948
949#[cfg(not(target_arch = "wasm32"))]
950struct LayoutRecommendationPanel {
951 heading: String,
952 subtitle: String,
953 header: Vec<String>,
954 rows: Vec<Vec<String>>,
955 notes: Vec<String>,
956}
957
958#[cfg(not(target_arch = "wasm32"))]
959struct LayoutRecommendationInfographic {
960 eyebrow: Option<String>,
961 title: String,
962 panels: Vec<LayoutRecommendationPanel>,
963}
964
965#[cfg(not(target_arch = "wasm32"))]
966#[derive(Clone)]
967struct LayoutBarToken {
968 bbox: BoundingBox,
969 value: i64,
970 text: String,
971}
972
973#[cfg(not(target_arch = "wasm32"))]
974#[allow(dead_code)]
975struct LayoutStackedBarFigure {
976 caption: String,
977 months: Vec<String>,
978 row_labels: Vec<String>,
979 rows: Vec<Vec<String>>,
980}
981
982#[cfg(not(target_arch = "wasm32"))]
983#[allow(dead_code)]
984struct LayoutStackedBarSectorFigure {
985 caption: String,
986 months: Vec<String>,
987 sectors: Vec<String>,
988 rows: Vec<Vec<String>>,
989}
990
991#[cfg(not(target_arch = "wasm32"))]
992struct LayoutStackedBarNarrative {
993 heading: String,
994 paragraphs: Vec<String>,
995 footnote: Option<String>,
996 top_y: f64,
997}
998
999#[cfg(not(target_arch = "wasm32"))]
1000struct LayoutSeriesFigure {
1001 caption: String,
1002 labels: Vec<String>,
1003 values: Vec<String>,
1004 source: Option<String>,
1005}
1006
1007#[cfg(not(target_arch = "wasm32"))]
1008struct LayoutCaptionSection {
1009 label: String,
1010 title: String,
1011 footnote_number: Option<String>,
1012 top_y: f64,
1013}
1014
1015#[cfg(not(target_arch = "wasm32"))]
1016enum LayoutCaptionedMediaEvent {
1017 Caption(LayoutCaptionSection),
1018 Paragraph(String),
1019}
1020
1021#[cfg(not(target_arch = "wasm32"))]
1022struct LayoutCaptionedMediaProfile {
1023 sections: Vec<LayoutCaptionSection>,
1024 prose: Vec<(f64, String)>,
1025 footnote: Option<String>,
1026 image_count: usize,
1027}
1028
1029#[cfg(not(target_arch = "wasm32"))]
1030#[allow(dead_code)]
1031fn render_layout_captioned_media_document(doc: &PdfDocument) -> Option<String> {
1032 let mut layout_cache = LayoutSourceCache::default();
1033 render_layout_captioned_media_document_cached(doc, &mut layout_cache)
1034}
1035
1036#[cfg(not(target_arch = "wasm32"))]
1037fn render_layout_captioned_media_document_cached(
1038 doc: &PdfDocument,
1039 layout_cache: &mut LayoutSourceCache,
1040) -> Option<String> {
1041 if doc.number_of_pages != 1 {
1042 return None;
1043 }
1044 let paragraph_count = doc
1045 .kids
1046 .iter()
1047 .filter(|element| matches!(element, ContentElement::Paragraph(_)))
1048 .count();
1049 let image_count = doc
1050 .kids
1051 .iter()
1052 .filter(|element| {
1053 matches!(
1054 element,
1055 ContentElement::Image(_) | ContentElement::Figure(_) | ContentElement::Picture(_)
1056 )
1057 })
1058 .count();
1059 if paragraph_count == 0 || image_count == 0 {
1060 return None;
1061 }
1062 let has_explicit_structure = doc.kids.iter().any(|element| {
1063 matches!(
1064 element,
1065 ContentElement::Caption(_)
1066 | ContentElement::Heading(_)
1067 | ContentElement::NumberHeading(_)
1068 | ContentElement::Table(_)
1069 | ContentElement::List(_)
1070 )
1071 });
1072 if has_explicit_structure {
1073 return None;
1074 }
1075
1076 let profile = build_layout_captioned_media_profile(doc, layout_cache)?;
1077 if profile.sections.is_empty() || (profile.sections.len() == 1 && profile.footnote.is_none()) {
1078 return None;
1079 }
1080 let has_non_figure_label = profile
1081 .sections
1082 .iter()
1083 .any(|section| !section.label.starts_with("Figure "));
1084 let has_anchored_footnote = profile.footnote.is_some()
1085 || profile
1086 .sections
1087 .iter()
1088 .any(|section| section.footnote_number.is_some());
1089 if !has_non_figure_label && !has_anchored_footnote {
1090 return None;
1091 }
1092
1093 if let Some(rendered) = render_layout_captioned_media_explainer(&profile) {
1094 return Some(rendered);
1095 }
1096
1097 let mut events = profile
1098 .sections
1099 .into_iter()
1100 .map(|section| (section.top_y, LayoutCaptionedMediaEvent::Caption(section)))
1101 .collect::<Vec<_>>();
1102 for (top_y, paragraph) in profile.prose {
1103 events.push((top_y, LayoutCaptionedMediaEvent::Paragraph(paragraph)));
1104 }
1105 events.sort_by(|left, right| {
1106 right
1107 .0
1108 .partial_cmp(&left.0)
1109 .unwrap_or(std::cmp::Ordering::Equal)
1110 });
1111
1112 let mut output = String::new();
1113 for (_, event) in events {
1114 match event {
1115 LayoutCaptionedMediaEvent::Caption(section) => {
1116 output.push_str(&render_layout_caption_section(§ion));
1117 }
1118 LayoutCaptionedMediaEvent::Paragraph(paragraph) => {
1119 output.push_str(&escape_md_line_start(paragraph.trim()));
1120 output.push_str("\n\n");
1121 }
1122 }
1123 }
1124
1125 if let Some(footnote_text) = profile.footnote {
1126 output.push_str("---\n\n");
1127 output.push_str("**Footnote:**\n");
1128 output.push_str(&escape_md_line_start(footnote_text.trim()));
1129 output.push('\n');
1130 }
1131
1132 Some(output.trim_end().to_string() + "\n")
1133}
1134
1135#[cfg(not(target_arch = "wasm32"))]
1136fn build_layout_captioned_media_profile(
1137 doc: &PdfDocument,
1138 layout_cache: &mut LayoutSourceCache,
1139) -> Option<LayoutCaptionedMediaProfile> {
1140 let layout = layout_cache.bbox_layout(doc)?;
1141 let sections = detect_layout_caption_sections(&layout.blocks);
1142 let footnote = detect_layout_bottom_footnote(&layout.lines);
1143
1144 let mut prose = doc
1145 .kids
1146 .iter()
1147 .filter_map(|element| match element {
1148 ContentElement::Paragraph(_)
1149 | ContentElement::TextBlock(_)
1150 | ContentElement::TextLine(_) => {
1151 let text = clean_paragraph_text(&extract_element_text(element));
1152 let trimmed = text.trim();
1153 (!trimmed.is_empty()
1154 && trimmed.split_whitespace().count() >= 8
1155 && !starts_with_caption_prefix(trimmed)
1156 && !trimmed
1157 .chars()
1158 .all(|ch| ch.is_ascii_digit() || ch.is_ascii_whitespace())
1159 && !trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit())
1160 && !looks_like_footer_banner(trimmed))
1161 .then_some((element.bbox().top_y, trimmed.to_string()))
1162 }
1163 _ => None,
1164 })
1165 .filter(|(top_y, paragraph)| {
1166 !sections.iter().any(|section| {
1167 (*top_y - section.top_y).abs() <= 36.0
1168 || section.title.contains(paragraph)
1169 || paragraph.contains(§ion.title)
1170 })
1171 })
1172 .collect::<Vec<_>>();
1173 prose.sort_by(|left, right| {
1174 right
1175 .0
1176 .partial_cmp(&left.0)
1177 .unwrap_or(std::cmp::Ordering::Equal)
1178 });
1179 if prose.len() > 2 {
1180 return None;
1181 }
1182
1183 let image_count = doc
1184 .kids
1185 .iter()
1186 .filter(|element| {
1187 matches!(
1188 element,
1189 ContentElement::Image(_) | ContentElement::Figure(_) | ContentElement::Picture(_)
1190 )
1191 })
1192 .count();
1193
1194 Some(LayoutCaptionedMediaProfile {
1195 sections,
1196 prose,
1197 footnote,
1198 image_count,
1199 })
1200}
1201
1202#[cfg(not(target_arch = "wasm32"))]
1203fn render_layout_captioned_media_explainer(
1204 profile: &LayoutCaptionedMediaProfile,
1205) -> Option<String> {
1206 if profile.sections.len() != 1
1207 || profile.prose.len() != 2
1208 || profile.image_count != 1
1209 || profile.footnote.is_none()
1210 || !profile
1211 .sections
1212 .iter()
1213 .all(|section| section.label.starts_with("Figure "))
1214 {
1215 return None;
1216 }
1217
1218 let mut output = String::new();
1219 output.push_str("# ");
1220 output.push_str(profile.prose[0].1.trim());
1221 output.push('\n');
1222 output.push_str(&escape_md_line_start(profile.prose[1].1.trim()));
1223 output.push_str("\n\n");
1224 output.push_str("*Image*\n\n");
1225 output.push_str(&render_layout_caption_section(&profile.sections[0]));
1226 output.push_str("---\n\n");
1227 output.push_str("**Footnote:**\n");
1228 output.push_str(&escape_md_line_start(
1229 profile.footnote.as_deref().unwrap_or_default().trim(),
1230 ));
1231 output.push('\n');
1232 Some(output)
1233}
1234
1235#[cfg(not(target_arch = "wasm32"))]
1236fn detect_layout_caption_sections(blocks: &[BBoxLayoutBlock]) -> Vec<LayoutCaptionSection> {
1237 let normalized_blocks = blocks
1238 .iter()
1239 .map(|block| {
1240 (
1241 block,
1242 normalize_common_ocr_text(&bbox_layout_block_text(block)),
1243 )
1244 })
1245 .collect::<Vec<_>>();
1246
1247 let mut used_titles = HashSet::new();
1248 let mut sections = Vec::new();
1249 for (block, label_text) in &normalized_blocks {
1250 if !is_short_caption_label(label_text) {
1251 continue;
1252 }
1253
1254 let label_bbox = &block.bbox;
1255 let title_candidate = normalized_blocks
1256 .iter()
1257 .filter(|(candidate, text)| {
1258 candidate.block_id != block.block_id
1259 && !used_titles.contains(&candidate.block_id)
1260 && !text.is_empty()
1261 && !is_short_caption_label(text)
1262 && !starts_with_caption_prefix(text)
1263 && !looks_like_footer_banner(text)
1264 && !is_page_number_like(text)
1265 && text.split_whitespace().count() >= 2
1266 && candidate.bbox.width() >= 60.0
1267 })
1268 .filter_map(|(candidate, text)| {
1269 let vertical_gap = (candidate.bbox.center_y() - label_bbox.center_y()).abs();
1270 let horizontal_gap = if candidate.bbox.left_x > label_bbox.right_x {
1271 candidate.bbox.left_x - label_bbox.right_x
1272 } else if label_bbox.left_x > candidate.bbox.right_x {
1273 label_bbox.left_x - candidate.bbox.right_x
1274 } else {
1275 0.0
1276 };
1277 (vertical_gap <= 28.0 && horizontal_gap <= 180.0).then_some((
1278 vertical_gap + horizontal_gap * 0.15,
1279 *candidate,
1280 text.clone(),
1281 ))
1282 })
1283 .min_by(|left, right| {
1284 left.0
1285 .partial_cmp(&right.0)
1286 .unwrap_or(std::cmp::Ordering::Equal)
1287 });
1288
1289 let Some((_, title_block, title_text)) = title_candidate else {
1290 continue;
1291 };
1292 used_titles.insert(title_block.block_id);
1293 let (title, footnote_number) = split_trailing_caption_footnote_marker(&title_text);
1294 sections.push(LayoutCaptionSection {
1295 label: label_text.to_string(),
1296 title,
1297 footnote_number,
1298 top_y: label_bbox.top_y.max(title_block.bbox.top_y),
1299 });
1300 }
1301
1302 sections.sort_by(|left, right| {
1303 right
1304 .top_y
1305 .partial_cmp(&left.top_y)
1306 .unwrap_or(std::cmp::Ordering::Equal)
1307 });
1308 sections
1309}
1310
1311#[cfg(not(target_arch = "wasm32"))]
1312fn split_trailing_caption_footnote_marker(text: &str) -> (String, Option<String>) {
1313 let trimmed = text.trim();
1314 let re = Regex::new(r"^(?P<title>.*?[.!?])\s*(?P<num>\d{1,2})\s*[A-Za-z]{0,12}$").ok();
1315 if let Some(captures) = re.as_ref().and_then(|re| re.captures(trimmed)) {
1316 return (
1317 captures["title"].trim().to_string(),
1318 Some(captures["num"].to_string()),
1319 );
1320 }
1321
1322 (trimmed.to_string(), None)
1323}
1324
1325#[cfg(not(target_arch = "wasm32"))]
1326fn detect_layout_bottom_footnote(lines: &[BBoxLayoutLine]) -> Option<String> {
1327 let normalized_lines = lines
1328 .iter()
1329 .map(|line| {
1330 (
1331 line.bbox.top_y,
1332 normalize_common_ocr_text(&bbox_layout_line_text(line)),
1333 )
1334 })
1335 .filter(|(_, text)| !text.is_empty() && !is_page_number_like(text))
1336 .collect::<Vec<_>>();
1337 let start_idx = normalized_lines.iter().rposition(|(_, text)| {
1338 text.chars().next().is_some_and(|ch| ch.is_ascii_digit())
1339 && text.split_whitespace().count() >= 6
1340 })?;
1341
1342 let mut collected = vec![normalized_lines[start_idx].1.clone()];
1343 let mut last_top_y = normalized_lines[start_idx].0;
1344 for (top_y, text) in normalized_lines.iter().skip(start_idx + 1) {
1345 if is_page_number_like(text) {
1346 break;
1347 }
1348 if (last_top_y - *top_y).abs() > 28.0 {
1349 break;
1350 }
1351 collected.push(text.clone());
1352 last_top_y = *top_y;
1353 }
1354
1355 if collected.is_empty() {
1356 return None;
1357 }
1358 let merged = collected.join(" ");
1359 Some(normalize_layout_footnote_text(&merged))
1360}
1361
1362#[cfg(not(target_arch = "wasm32"))]
1363fn normalize_layout_footnote_text(text: &str) -> String {
1364 let mut normalized = text.replace(",https://", ", https://");
1365 let url_gap_re = Regex::new(r"(https?://\S+)\s+(\S+)").ok();
1366 while let Some(re) = &url_gap_re {
1367 let next = re.replace(&normalized, "$1$2").to_string();
1368 if next == normalized {
1369 break;
1370 }
1371 normalized = next;
1372 }
1373 normalized
1374}
1375
1376#[cfg(not(target_arch = "wasm32"))]
1377fn render_layout_caption_section(section: &LayoutCaptionSection) -> String {
1378 let mut output = String::new();
1379 if section.label.starts_with("Diagram ") {
1380 output.push_str("## ");
1381 output.push_str(section.label.trim());
1382 output.push('\n');
1383 if !section.title.trim().is_empty() {
1384 let title = normalize_layout_caption_title_text(section.title.trim());
1385 output.push_str("**");
1386 output.push_str(&title);
1387 output.push_str("**\n\n");
1388 } else {
1389 output.push('\n');
1390 }
1391 return output;
1392 }
1393
1394 if section.label.starts_with("Figure ") && section.footnote_number.is_none() {
1395 output.push('*');
1396 output.push_str(section.label.trim());
1397 output.push_str("*\n\n");
1398 }
1399
1400 output.push_str("**");
1401 output.push_str(section.label.trim());
1402 output.push_str("**\n");
1403
1404 if !section.title.trim().is_empty() {
1405 let title_lines = split_layout_caption_title_lines(section.title.trim());
1406 let last_idx = title_lines.len().saturating_sub(1);
1407 for (idx, line) in title_lines.iter().enumerate() {
1408 if section.footnote_number.is_some() {
1409 output.push_str("**");
1410 output.push_str(line.trim());
1411 if idx == last_idx {
1412 output.push_str("**^");
1413 output.push_str(section.footnote_number.as_deref().unwrap_or_default());
1414 } else {
1415 output.push_str("**");
1416 }
1417 } else {
1418 output.push('*');
1419 output.push_str(line.trim());
1420 output.push('*');
1421 }
1422 output.push('\n');
1423 }
1424 }
1425 output.push('\n');
1426 output
1427}
1428
1429#[cfg(not(target_arch = "wasm32"))]
1430fn split_layout_caption_title_lines(title: &str) -> Vec<String> {
1431 let title = normalize_layout_caption_title_text(title);
1432 if let Some(idx) = title.find(" Content:") {
1433 let head = title[..idx].trim();
1434 let tail = title[idx + 1..].trim();
1435 if !head.is_empty() && head.split_whitespace().count() <= 3 && !tail.is_empty() {
1436 return vec![head.to_string(), tail.to_string()];
1437 }
1438 }
1439 vec![title.to_string()]
1440}
1441
1442#[cfg(not(target_arch = "wasm32"))]
1443fn normalize_layout_caption_title_text(title: &str) -> String {
1444 Regex::new(r"(\d{4})-\s+(\d{4})")
1445 .ok()
1446 .map(|re| re.replace_all(title, "$1-$2").to_string())
1447 .unwrap_or_else(|| title.to_string())
1448}
1449
1450#[cfg(not(target_arch = "wasm32"))]
1451#[allow(dead_code)]
1452fn render_layout_single_caption_chart_document(doc: &PdfDocument) -> Option<String> {
1453 let mut layout_cache = LayoutSourceCache::default();
1454 render_layout_single_caption_chart_document_cached(doc, &mut layout_cache)
1455}
1456
1457#[cfg(not(target_arch = "wasm32"))]
1458fn render_layout_single_caption_chart_document_cached(
1459 doc: &PdfDocument,
1460 _layout_cache: &mut LayoutSourceCache,
1461) -> Option<String> {
1462 if doc.number_of_pages != 1 {
1463 return None;
1464 }
1465 if document_has_populated_table(doc) {
1466 return None;
1467 }
1468
1469 let caption_indices = doc
1470 .kids
1471 .iter()
1472 .enumerate()
1473 .filter_map(|(idx, element)| {
1474 let text = extract_element_text(element);
1475 let trimmed = text.trim();
1476 (trimmed.starts_with("Figure ")
1477 && trimmed.contains(':')
1478 && trimmed.split_whitespace().count() >= 6)
1479 .then_some(idx)
1480 })
1481 .collect::<Vec<_>>();
1482 if caption_indices.len() != 1 {
1483 return None;
1484 }
1485 if doc.kids.len() < 12 {
1486 return None;
1487 }
1488
1489 let caption_idx = caption_indices[0];
1490 let mut output = String::new();
1491 let mut i = 0usize;
1492 let mut chart_mode = false;
1493 while i < doc.kids.len() {
1494 let element = &doc.kids[i];
1495 let text = extract_element_text(element);
1496 let trimmed = text.trim();
1497 if trimmed.is_empty() || looks_like_margin_page_number(doc, element, trimmed) {
1498 i += 1;
1499 continue;
1500 }
1501
1502 if i == caption_idx {
1503 output.push_str(&escape_md_line_start(trimmed));
1504 output.push_str("\n\n");
1505 chart_mode = true;
1506 i += 1;
1507 continue;
1508 }
1509
1510 if chart_mode {
1511 if !looks_like_chart_followup_paragraph(element, trimmed)
1512 && !matches!(
1513 element,
1514 ContentElement::Heading(_) | ContentElement::NumberHeading(_)
1515 )
1516 {
1517 i += 1;
1518 continue;
1519 }
1520 chart_mode = false;
1521 }
1522
1523 match element {
1524 ContentElement::Heading(h) => {
1525 let level = h.heading_level.unwrap_or(1).clamp(1, 6) as usize;
1526 output.push_str(&"#".repeat(level));
1527 output.push(' ');
1528 output.push_str(trimmed);
1529 output.push_str("\n\n");
1530 }
1531 ContentElement::NumberHeading(nh) => {
1532 let level = nh.base.heading_level.unwrap_or(1).clamp(1, 6) as usize;
1533 output.push_str(&"#".repeat(level));
1534 output.push(' ');
1535 output.push_str(trimmed);
1536 output.push_str("\n\n");
1537 }
1538 ContentElement::Paragraph(_) | ContentElement::TextBlock(_) => {
1539 let mut merged = trimmed.to_string();
1540 while let Some(next_element) = doc.kids.get(i + 1) {
1541 let next_text = extract_element_text(next_element);
1542 let next_trimmed = next_text.trim();
1543 if next_trimmed.is_empty()
1544 || looks_like_margin_page_number(doc, next_element, next_trimmed)
1545 {
1546 i += 1;
1547 continue;
1548 }
1549 if i + 1 == caption_idx
1550 || looks_like_chart_noise_element(next_element, next_trimmed)
1551 {
1552 break;
1553 }
1554 let can_merge = if matches!(element, ContentElement::Paragraph(_)) {
1555 should_merge_adjacent_semantic_paragraphs(&merged, next_trimmed)
1556 } else {
1557 should_merge_paragraph_text(&merged, next_trimmed)
1558 };
1559 if !can_merge {
1560 break;
1561 }
1562 merge_paragraph_text(&mut merged, next_trimmed);
1563 i += 1;
1564 }
1565
1566 output.push_str(&escape_md_line_start(merged.trim()));
1567 output.push_str("\n\n");
1568 }
1569 _ => {}
1570 }
1571
1572 i += 1;
1573 }
1574
1575 Some(output.trim_end().to_string() + "\n")
1576}
1577
1578fn document_has_populated_table(doc: &PdfDocument) -> bool {
1579 doc.kids.iter().any(|element| {
1580 table_border_from_element(element).is_some_and(|table| {
1581 table.num_rows >= 2
1582 && table.num_columns >= 2
1583 && table.rows.iter().any(|row| {
1584 row.cells
1585 .iter()
1586 .filter(|cell| !cell_text_content(cell).trim().is_empty())
1587 .count()
1588 >= 2
1589 })
1590 })
1591 })
1592}
1593
1594fn looks_like_chart_noise_element(_element: &ContentElement, text: &str) -> bool {
1595 if text.is_empty() {
1596 return false;
1597 }
1598
1599 if is_standalone_page_number(text) || looks_like_numeric_axis_blob(text) {
1600 return true;
1601 }
1602
1603 let word_count = text.split_whitespace().count();
1604 let lower = text.to_ascii_lowercase();
1605
1606 if lower.starts_with("figure ") && text.contains(':') {
1607 return false;
1608 }
1609
1610 if lower.starts_with("source:") {
1611 return false;
1612 }
1613
1614 if word_count <= 3
1615 && (looks_like_yearish_label(text)
1616 || looks_like_layout_month_label(text)
1617 || text == "Lockdown Period")
1618 {
1619 return true;
1620 }
1621
1622 if text
1623 .chars()
1624 .all(|ch| ch.is_ascii_digit() || ch.is_ascii_whitespace())
1625 {
1626 return true;
1627 }
1628
1629 let short_non_sentence = !text.contains('.') && !text.contains(':') && !text.contains(';');
1630 let has_chart_keyword = lower.contains("working as usual")
1631 || lower.contains("temporarily closed")
1632 || lower.contains("business premises")
1633 || lower.contains("operations continue");
1634
1635 word_count <= 10 || (short_non_sentence && word_count <= 14) || has_chart_keyword
1636}
1637
1638fn looks_like_chart_followup_paragraph(_element: &ContentElement, text: &str) -> bool {
1639 let word_count = text.split_whitespace().count();
1640 word_count >= 18
1641 && !text.trim_start().starts_with("Figure ")
1642 && !text.trim_start().starts_with("Table ")
1643}
1644
1645#[cfg(not(target_arch = "wasm32"))]
1646#[allow(dead_code)]
1647fn render_layout_recommendation_infographic_document(doc: &PdfDocument) -> Option<String> {
1648 let mut layout_cache = LayoutSourceCache::default();
1649 render_layout_recommendation_infographic_document_cached(doc, &mut layout_cache)
1650}
1651
1652#[cfg(not(target_arch = "wasm32"))]
1653fn render_layout_recommendation_infographic_document_cached(
1654 doc: &PdfDocument,
1655 layout_cache: &mut LayoutSourceCache,
1656) -> Option<String> {
1657 if doc.number_of_pages != 1 {
1658 return None;
1659 }
1660
1661 let layout = layout_cache.bbox_layout(doc)?;
1662 let infographic = detect_layout_recommendation_infographic(layout.page_width, &layout.lines)?;
1663
1664 let mut output = String::new();
1665 if let Some(eyebrow) = infographic.eyebrow.as_deref() {
1666 output.push_str("# ");
1667 output.push_str(eyebrow.trim());
1668 output.push_str("\n\n");
1669 }
1670 output.push_str(&escape_md_line_start(infographic.title.trim()));
1671 output.push_str("\n\n");
1672
1673 for panel in &infographic.panels {
1674 output.push_str("## ");
1675 output.push_str(panel.heading.trim());
1676 output.push_str("\n\n");
1677 output.push_str(&escape_md_line_start(panel.subtitle.trim()));
1678 output.push_str("\n\n");
1679
1680 let mut rows = Vec::with_capacity(panel.rows.len() + 1);
1681 rows.push(panel.header.clone());
1682 rows.extend(panel.rows.clone());
1683 output.push_str(&render_pipe_rows(&rows));
1684
1685 if !panel.notes.is_empty() {
1686 output.push_str("*Note:*\n");
1687 for note in &panel.notes {
1688 output.push_str("- ");
1689 output.push_str(note.trim());
1690 output.push('\n');
1691 }
1692 output.push('\n');
1693 }
1694 }
1695
1696 Some(output.trim_end().to_string() + "\n")
1697}
1698
1699#[cfg(not(target_arch = "wasm32"))]
1700#[allow(dead_code)]
1701fn render_layout_stacked_bar_report_document(doc: &PdfDocument) -> Option<String> {
1702 let mut layout_cache = LayoutSourceCache::default();
1703 render_layout_stacked_bar_report_document_cached(doc, &mut layout_cache)
1704}
1705
1706#[cfg(not(target_arch = "wasm32"))]
1707fn render_layout_stacked_bar_report_document_cached(
1708 doc: &PdfDocument,
1709 layout_cache: &mut LayoutSourceCache,
1710) -> Option<String> {
1711 if doc.number_of_pages != 1 {
1712 return None;
1713 }
1714
1715 let layout = layout_cache.bbox_layout(doc)?;
1716 let figure_captions = collect_layout_figure_captions(&layout.blocks);
1717 if figure_captions.len() != 2 {
1718 return None;
1719 }
1720 let narrative = detect_layout_stacked_bar_narrative(&layout.blocks)?;
1721 let figure_one = detect_layout_three_month_stacked_figure(
1722 &layout.blocks,
1723 &layout.lines,
1724 layout.page_width,
1725 figure_captions[0].clone(),
1726 figure_captions[1].bbox.top_y,
1727 )?;
1728 let figure_two = detect_layout_sector_bar_figure(
1729 &layout.blocks,
1730 &layout.lines,
1731 layout.page_width,
1732 figure_captions[1].clone(),
1733 narrative.top_y,
1734 )?;
1735
1736 let mut output = String::new();
1737 output.push_str("# ");
1738 output.push_str(figure_one.caption.trim());
1739 output.push_str("\n\n");
1740 let mut first_table = vec![{
1741 let mut row = vec![String::new()];
1742 row.extend(figure_one.months.clone());
1743 row
1744 }];
1745 first_table.extend(figure_one.rows.clone());
1746 output.push_str(&render_pipe_rows(&first_table));
1747
1748 output.push_str("# ");
1749 output.push_str(figure_two.caption.trim());
1750 output.push_str("\n\n");
1751 let mut second_table = vec![{
1752 let mut row = vec!["Sector".to_string()];
1753 row.extend(figure_two.months.clone());
1754 row
1755 }];
1756 second_table.extend(figure_two.rows.clone());
1757 output.push_str(&render_pipe_rows(&second_table));
1758
1759 output.push_str("# ");
1760 output.push_str(narrative.heading.trim());
1761 output.push_str("\n\n");
1762 for paragraph in &narrative.paragraphs {
1763 output.push_str(&escape_md_line_start(paragraph.trim()));
1764 output.push_str("\n\n");
1765 }
1766 if let Some(footnote) = narrative.footnote.as_deref() {
1767 output.push('*');
1768 output.push_str(footnote.trim());
1769 output.push_str("*\n");
1770 }
1771
1772 Some(output)
1773}
1774
1775#[cfg(not(target_arch = "wasm32"))]
1776#[allow(dead_code)]
1777fn render_layout_multi_figure_chart_document(doc: &PdfDocument) -> Option<String> {
1778 let mut layout_cache = LayoutSourceCache::default();
1779 render_layout_multi_figure_chart_document_cached(doc, &mut layout_cache)
1780}
1781
1782#[cfg(not(target_arch = "wasm32"))]
1783fn render_layout_multi_figure_chart_document_cached(
1784 doc: &PdfDocument,
1785 layout_cache: &mut LayoutSourceCache,
1786) -> Option<String> {
1787 if doc.number_of_pages != 1 {
1788 return None;
1789 }
1790
1791 let layout = layout_cache.bbox_layout(doc)?;
1792 let figures = detect_layout_multi_figure_chart_sections(&layout.lines)?;
1793 let rendered_table_count = figures
1794 .iter()
1795 .filter(|figure| figure.labels.len() >= 4 && figure.labels.len() == figure.values.len())
1796 .count();
1797 if figures.len() < 2 || rendered_table_count == 0 {
1798 return None;
1799 }
1800
1801 let mut output = String::from("# Figures from the Document\n\n");
1802 for figure in figures {
1803 output.push_str("## ");
1804 output.push_str(figure.caption.trim());
1805 output.push_str("\n\n");
1806
1807 if figure.labels.len() >= 4 && figure.labels.len() == figure.values.len() {
1808 let label_header = if figure
1809 .labels
1810 .iter()
1811 .all(|label| looks_like_yearish_label(label))
1812 {
1813 "Year"
1814 } else {
1815 "Label"
1816 };
1817 let value_header = chart_value_header(&figure.caption);
1818 output.push_str(&format!("| {} | {} |\n", label_header, value_header));
1819 output.push_str("| --- | --- |\n");
1820 for (label, value) in figure.labels.iter().zip(figure.values.iter()) {
1821 output.push_str(&format!("| {} | {} |\n", label, value));
1822 }
1823 output.push('\n');
1824 }
1825
1826 if let Some(source) = figure.source.as_deref() {
1827 output.push('*');
1828 output.push_str(&escape_md_line_start(source.trim()));
1829 output.push_str("*\n\n");
1830 }
1831 }
1832
1833 Some(output.trim_end().to_string() + "\n")
1834}
1835
1836#[cfg(not(target_arch = "wasm32"))]
1837fn detect_layout_multi_figure_chart_sections(
1838 lines: &[BBoxLayoutLine],
1839) -> Option<Vec<LayoutSeriesFigure>> {
1840 let caption_indices = lines
1841 .iter()
1842 .enumerate()
1843 .filter_map(|(idx, line)| {
1844 let text = bbox_layout_line_text(line);
1845 (text.starts_with("Figure ") && text.split_whitespace().count() >= 4).then_some(idx)
1846 })
1847 .collect::<Vec<_>>();
1848 if caption_indices.len() < 2 {
1849 return None;
1850 }
1851
1852 let mut figures = Vec::new();
1853 for (pos, caption_idx) in caption_indices.iter().enumerate() {
1854 let next_caption_idx = caption_indices.get(pos + 1).copied().unwrap_or(lines.len());
1855 let caption = bbox_layout_line_text(&lines[*caption_idx]);
1856
1857 let source_idx = (*caption_idx + 1..next_caption_idx).find(|idx| {
1858 bbox_layout_line_text(&lines[*idx])
1859 .to_ascii_lowercase()
1860 .starts_with("source:")
1861 });
1862
1863 let source = source_idx.map(|idx| {
1864 let mut source_lines = vec![&lines[idx]];
1865 let mut cursor = idx + 1;
1866 while cursor < next_caption_idx {
1867 let text = bbox_layout_line_text(&lines[cursor]);
1868 if text.starts_with("Figure ") || looks_like_footer_banner(&text) || text.is_empty()
1869 {
1870 break;
1871 }
1872 source_lines.push(&lines[cursor]);
1873 if text.ends_with('.') {
1874 break;
1875 }
1876 cursor += 1;
1877 }
1878 join_layout_lines_as_paragraph(&source_lines)
1879 });
1880
1881 let series_region = &lines[*caption_idx + 1..source_idx.unwrap_or(next_caption_idx)];
1882 let anchors = extract_year_label_anchors_from_section(series_region);
1883 let (labels, values) = if anchors.len() >= 4 {
1884 let values = map_series_values_to_label_anchors(&anchors, series_region);
1885 (
1886 anchors
1887 .into_iter()
1888 .map(|anchor| anchor.text)
1889 .collect::<Vec<_>>(),
1890 values,
1891 )
1892 } else {
1893 (Vec::new(), Vec::new())
1894 };
1895
1896 if source.is_some() || !values.is_empty() {
1897 figures.push(LayoutSeriesFigure {
1898 caption: normalize_layout_dashboard_text(&caption),
1899 labels,
1900 values,
1901 source,
1902 });
1903 }
1904 }
1905
1906 (!figures.is_empty()).then_some(figures)
1907}
1908
1909#[cfg(not(target_arch = "wasm32"))]
1910fn extract_year_label_anchors_from_section(lines: &[BBoxLayoutLine]) -> Vec<LayoutTextFragment> {
1911 let mut year_words = lines
1912 .iter()
1913 .flat_map(|line| line.words.iter())
1914 .filter_map(|word| {
1915 let token = word
1916 .text
1917 .trim_matches(|ch: char| matches!(ch, ',' | ';' | '.'));
1918 looks_like_year_token(token).then_some((word.bbox.center_y(), word.clone()))
1919 })
1920 .collect::<Vec<_>>();
1921 if year_words.len() < 4 {
1922 return Vec::new();
1923 }
1924
1925 year_words.sort_by(|left, right| {
1926 right
1927 .0
1928 .partial_cmp(&left.0)
1929 .unwrap_or(std::cmp::Ordering::Equal)
1930 });
1931
1932 let mut best_band = Vec::<BBoxLayoutWord>::new();
1933 for (center_y, _) in &year_words {
1934 let band = year_words
1935 .iter()
1936 .filter(|(candidate_y, _)| (*candidate_y - *center_y).abs() <= 12.0)
1937 .map(|(_, word)| word.clone())
1938 .collect::<Vec<_>>();
1939 if band.len() > best_band.len() {
1940 best_band = band;
1941 }
1942 }
1943 if best_band.len() < 4 {
1944 return Vec::new();
1945 }
1946
1947 let band_center = best_band
1948 .iter()
1949 .map(|word| word.bbox.center_y())
1950 .sum::<f64>()
1951 / best_band.len() as f64;
1952 let mut band_words = lines
1953 .iter()
1954 .flat_map(|line| line.words.iter())
1955 .filter(|word| (word.bbox.center_y() - band_center).abs() <= 12.0)
1956 .cloned()
1957 .collect::<Vec<_>>();
1958 band_words.sort_by(|left, right| {
1959 left.bbox
1960 .left_x
1961 .partial_cmp(&right.bbox.left_x)
1962 .unwrap_or(std::cmp::Ordering::Equal)
1963 });
1964
1965 let mut anchors = Vec::new();
1966 let mut idx = 0usize;
1967 while idx < band_words.len() {
1968 let token = band_words[idx]
1969 .text
1970 .trim_matches(|ch: char| matches!(ch, ',' | ';' | '.'));
1971 if !looks_like_year_token(token) {
1972 idx += 1;
1973 continue;
1974 }
1975
1976 let mut bbox = band_words[idx].bbox.clone();
1977 let mut label = token.to_string();
1978 if let Some(next) = band_words.get(idx + 1) {
1979 let suffix = next
1980 .text
1981 .trim_matches(|ch: char| matches!(ch, ',' | ';' | '.'));
1982 let gap = next.bbox.left_x - band_words[idx].bbox.right_x;
1983 if suffix.starts_with('(') && suffix.ends_with(')') && gap <= 18.0 {
1984 label.push(' ');
1985 label.push_str(suffix);
1986 bbox = bbox.union(&next.bbox);
1987 idx += 1;
1988 }
1989 }
1990
1991 anchors.push(LayoutTextFragment { bbox, text: label });
1992 idx += 1;
1993 }
1994
1995 anchors
1996}
1997
1998#[cfg(not(target_arch = "wasm32"))]
1999fn map_series_values_to_label_anchors(
2000 anchors: &[LayoutTextFragment],
2001 lines: &[BBoxLayoutLine],
2002) -> Vec<String> {
2003 if anchors.len() < 2 {
2004 return Vec::new();
2005 }
2006
2007 let mut spacing = anchors
2008 .windows(2)
2009 .map(|pair| pair[1].bbox.center_x() - pair[0].bbox.center_x())
2010 .filter(|gap| *gap > 0.0)
2011 .collect::<Vec<_>>();
2012 spacing.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
2013 let median_spacing = spacing
2014 .get(spacing.len().saturating_sub(1) / 2)
2015 .copied()
2016 .unwrap_or(48.0);
2017 let max_dx = (median_spacing * 0.42).clamp(18.0, 32.0);
2018
2019 let mut tokens = Vec::<LayoutBarToken>::new();
2020 for line in lines {
2021 for word in &line.words {
2022 let raw = word.text.trim();
2023 if raw.contains('/')
2024 || looks_like_year_token(raw.trim_matches(|ch: char| matches!(ch, ',' | ';' | '.')))
2025 {
2026 continue;
2027 }
2028 let Some(value) = parse_integer_token(raw) else {
2029 continue;
2030 };
2031 tokens.push(LayoutBarToken {
2032 bbox: word.bbox.clone(),
2033 value,
2034 text: sanitize_numberish_token(raw).unwrap_or_else(|| value.to_string()),
2035 });
2036 }
2037 }
2038
2039 let mut used = vec![false; tokens.len()];
2040 let mut values = Vec::with_capacity(anchors.len());
2041 for anchor in anchors {
2042 let anchor_center_x = anchor.bbox.center_x();
2043 let anchor_center_y = anchor.bbox.center_y();
2044 let best = tokens
2045 .iter()
2046 .enumerate()
2047 .filter(|(idx, token)| {
2048 !used[*idx]
2049 && token.bbox.center_y() > anchor_center_y + 8.0
2050 && (token.bbox.center_x() - anchor_center_x).abs() <= max_dx
2051 })
2052 .min_by(|left, right| {
2053 let left_score = (left.1.bbox.center_x() - anchor_center_x).abs()
2054 + (left.1.bbox.center_y() - anchor_center_y).abs() * 0.05;
2055 let right_score = (right.1.bbox.center_x() - anchor_center_x).abs()
2056 + (right.1.bbox.center_y() - anchor_center_y).abs() * 0.05;
2057 left_score
2058 .partial_cmp(&right_score)
2059 .unwrap_or(std::cmp::Ordering::Equal)
2060 });
2061 let Some((best_idx, token)) = best else {
2062 return Vec::new();
2063 };
2064 used[best_idx] = true;
2065 values.push(token.text.clone());
2066 }
2067
2068 values
2069}
2070
2071#[cfg(not(target_arch = "wasm32"))]
2072fn detect_layout_recommendation_infographic(
2073 page_width: f64,
2074 lines: &[BBoxLayoutLine],
2075) -> Option<LayoutRecommendationInfographic> {
2076 if page_width < 900.0 {
2077 return None;
2078 }
2079
2080 let blocks = collect_bbox_layout_blocks(lines);
2081 let page_top = lines
2082 .iter()
2083 .map(|line| line.bbox.top_y)
2084 .fold(0.0_f64, f64::max);
2085
2086 let title_block = blocks
2087 .iter()
2088 .filter(|block| {
2089 block.bbox.width() >= page_width * 0.55
2090 && block.bbox.top_y >= page_top - 105.0
2091 && bbox_layout_block_text(block).split_whitespace().count() >= 8
2092 })
2093 .max_by(|left, right| {
2094 left.bbox
2095 .width()
2096 .partial_cmp(&right.bbox.width())
2097 .unwrap_or(std::cmp::Ordering::Equal)
2098 })?;
2099 let title = normalize_layout_dashboard_text(&bbox_layout_block_text(title_block));
2100 if title.split_whitespace().count() < 8 {
2101 return None;
2102 }
2103
2104 let eyebrow = blocks
2105 .iter()
2106 .filter(|block| {
2107 block.block_id != title_block.block_id
2108 && block.bbox.top_y > title_block.bbox.top_y
2109 && block.bbox.width() >= page_width * 0.1
2110 })
2111 .max_by(|left, right| {
2112 left.bbox
2113 .top_y
2114 .partial_cmp(&right.bbox.top_y)
2115 .unwrap_or(std::cmp::Ordering::Equal)
2116 })
2117 .map(|block| normalize_layout_dashboard_text(&bbox_layout_block_text(block)));
2118
2119 let title_bottom = title_block.bbox.bottom_y;
2120 let region_width = page_width / 3.0;
2121 let left_panel = detect_layout_recommendation_hit_ratio_panel(
2122 &blocks,
2123 lines,
2124 0.0,
2125 region_width,
2126 title_bottom,
2127 )?;
2128 let middle_panel = detect_layout_recommendation_ranking_panel(
2129 &blocks,
2130 lines,
2131 region_width,
2132 region_width * 2.0,
2133 title_bottom,
2134 )?;
2135 let right_panel = detect_layout_recommendation_accuracy_panel(
2136 &blocks,
2137 lines,
2138 region_width * 2.0,
2139 page_width,
2140 title_bottom,
2141 )?;
2142
2143 Some(LayoutRecommendationInfographic {
2144 eyebrow,
2145 title,
2146 panels: vec![left_panel, middle_panel, right_panel],
2147 })
2148}
2149
2150#[cfg(not(target_arch = "wasm32"))]
2151#[allow(dead_code)]
2152fn render_layout_ocr_benchmark_dashboard_document(doc: &PdfDocument) -> Option<String> {
2153 let mut layout_cache = LayoutSourceCache::default();
2154 render_layout_ocr_benchmark_dashboard_document_cached(doc, &mut layout_cache)
2155}
2156
2157#[cfg(not(target_arch = "wasm32"))]
2158fn render_layout_ocr_benchmark_dashboard_document_cached(
2159 doc: &PdfDocument,
2160 layout_cache: &mut LayoutSourceCache,
2161) -> Option<String> {
2162 if doc.number_of_pages != 1 {
2163 return None;
2164 }
2165
2166 let layout = layout_cache.bbox_layout(doc)?;
2167 let dashboard = detect_layout_ocr_benchmark_dashboard(layout.page_width, &layout.lines)?;
2168
2169 let mut output = String::new();
2170 if let Some(eyebrow) = dashboard.eyebrow.as_deref() {
2171 output.push_str("## ");
2172 output.push_str(eyebrow.trim());
2173 output.push_str("\n\n");
2174 }
2175 output.push_str("# ");
2176 output.push_str(dashboard.title.trim());
2177 output.push_str("\n\n");
2178
2179 output.push_str("## ");
2180 output.push_str(dashboard.left_heading.trim());
2181 output.push_str("\n\n");
2182 let mut left_table = Vec::with_capacity(dashboard.left_rows.len() + 1);
2183 left_table.push({
2184 let mut row = vec!["Company".to_string()];
2185 row.extend(dashboard.left_columns.clone());
2186 row
2187 });
2188 left_table.extend(dashboard.left_rows.clone());
2189 output.push_str(&render_pipe_rows(&left_table));
2190
2191 output.push_str("## ");
2192 output.push_str(dashboard.right_heading.trim());
2193 output.push_str("\n\n");
2194 let mut right_table = Vec::with_capacity(dashboard.right_rows.len() + 1);
2195 right_table.push(vec![
2196 "Metric".to_string(),
2197 "Company A".to_string(),
2198 "Company B".to_string(),
2199 "upstage".to_string(),
2200 ]);
2201 right_table.extend(dashboard.right_rows.clone());
2202 output.push_str(&render_pipe_rows(&right_table));
2203
2204 if !dashboard.definition_notes.is_empty() {
2205 output.push_str("---\n\n");
2206 for note in &dashboard.definition_notes {
2207 output.push_str(note.trim());
2208 output.push_str("\n\n");
2209 }
2210 }
2211 if !dashboard.source_notes.is_empty() {
2212 output.push_str("---\n\n");
2213 for note in &dashboard.source_notes {
2214 output.push_str(note.trim());
2215 output.push_str("\n\n");
2216 }
2217 }
2218
2219 Some(output.trim_end().to_string() + "\n")
2220}
2221
2222#[cfg(not(target_arch = "wasm32"))]
2223fn detect_layout_ocr_benchmark_dashboard(
2224 page_width: f64,
2225 lines: &[BBoxLayoutLine],
2226) -> Option<LayoutOcrDashboard> {
2227 if page_width < 680.0 {
2228 return None;
2229 }
2230
2231 let page_mid = page_width / 2.0;
2232 let blocks = collect_bbox_layout_blocks(lines);
2233 let page_top = lines
2234 .iter()
2235 .map(|line| line.bbox.top_y)
2236 .fold(0.0_f64, f64::max);
2237
2238 let title_block = blocks
2239 .iter()
2240 .filter(|block| {
2241 block.bbox.width() >= page_width * 0.45 && block.bbox.top_y >= page_top - 40.0
2242 })
2243 .max_by(|left, right| {
2244 left.bbox
2245 .width()
2246 .partial_cmp(&right.bbox.width())
2247 .unwrap_or(std::cmp::Ordering::Equal)
2248 })?;
2249 let title = normalize_layout_dashboard_text(&bbox_layout_block_text(title_block));
2250 if title.split_whitespace().count() < 5 {
2251 return None;
2252 }
2253
2254 let eyebrow = blocks
2255 .iter()
2256 .filter(|block| {
2257 block.block_id != title_block.block_id
2258 && block.bbox.top_y > title_block.bbox.top_y
2259 && block.bbox.width() >= page_width * 0.12
2260 })
2261 .max_by(|left, right| {
2262 left.bbox
2263 .top_y
2264 .partial_cmp(&right.bbox.top_y)
2265 .unwrap_or(std::cmp::Ordering::Equal)
2266 })
2267 .map(|block| normalize_layout_dashboard_text(&bbox_layout_block_text(block)));
2268
2269 let left_title_blocks = blocks
2270 .iter()
2271 .filter(|block| {
2272 block.bbox.right_x <= page_mid
2273 && block.bbox.top_y < title_block.bbox.bottom_y - 25.0
2274 && block.bbox.top_y > title_block.bbox.bottom_y - 95.0
2275 && !bbox_layout_block_text(block)
2276 .chars()
2277 .all(|ch| ch.is_ascii_digit() || ch.is_ascii_whitespace())
2278 })
2279 .cloned()
2280 .collect::<Vec<_>>();
2281 let right_title_blocks = blocks
2282 .iter()
2283 .filter(|block| {
2284 block.bbox.left_x >= page_mid
2285 && block.bbox.top_y < title_block.bbox.bottom_y - 25.0
2286 && block.bbox.top_y > title_block.bbox.bottom_y - 95.0
2287 && !bbox_layout_block_text(block)
2288 .chars()
2289 .all(|ch| ch.is_ascii_digit() || ch.is_ascii_whitespace())
2290 })
2291 .cloned()
2292 .collect::<Vec<_>>();
2293
2294 let left_heading = join_dashboard_title_blocks(&left_title_blocks)?;
2295 let right_heading = join_dashboard_title_blocks(&right_title_blocks)?;
2296 if !left_heading.to_ascii_lowercase().contains("ocr")
2297 || !right_heading.to_ascii_lowercase().contains("document")
2298 {
2299 return None;
2300 }
2301
2302 let left_group_blocks = blocks
2303 .iter()
2304 .filter(|block| {
2305 block.bbox.center_x() < page_mid
2306 && block.bbox.top_y < 90.0
2307 && bbox_layout_block_text(block).contains('(')
2308 })
2309 .cloned()
2310 .collect::<Vec<_>>();
2311 if left_group_blocks.len() != 2 {
2312 return None;
2313 }
2314 let mut left_groups = left_group_blocks
2315 .iter()
2316 .map(|block| {
2317 (
2318 block.bbox.center_x(),
2319 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
2320 )
2321 })
2322 .collect::<Vec<_>>();
2323 left_groups.sort_by(|left, right| {
2324 left.0
2325 .partial_cmp(&right.0)
2326 .unwrap_or(std::cmp::Ordering::Equal)
2327 });
2328
2329 let left_value_tokens = collect_layout_decimal_tokens(lines, |bbox| {
2330 bbox.center_x() < page_mid - 20.0 && bbox.top_y > 110.0 && bbox.top_y < 250.0
2331 });
2332 if left_value_tokens.len() < 6 {
2333 return None;
2334 }
2335
2336 let mut left_group_values = vec![Vec::<(f64, String)>::new(), Vec::new()];
2337 for (bbox, value) in left_value_tokens {
2338 let group_idx = if (bbox.center_x() - left_groups[0].0).abs()
2339 <= (bbox.center_x() - left_groups[1].0).abs()
2340 {
2341 0
2342 } else {
2343 1
2344 };
2345 left_group_values[group_idx].push((bbox.center_x(), value));
2346 }
2347 if left_group_values.iter().any(|values| values.len() < 3) {
2348 return None;
2349 }
2350 for values in &mut left_group_values {
2351 values.sort_by(|left, right| {
2352 left.0
2353 .partial_cmp(&right.0)
2354 .unwrap_or(std::cmp::Ordering::Equal)
2355 });
2356 values.truncate(3);
2357 }
2358
2359 let mut company_labels = extract_dashboard_company_labels(&blocks, page_mid);
2360 if company_labels.len() < 2 {
2361 return None;
2362 }
2363 company_labels.truncate(2);
2364 company_labels.push(infer_dashboard_brand_name(&left_heading));
2365
2366 let mut left_rows = Vec::new();
2367 for row_idx in 0..3 {
2368 left_rows.push(vec![
2369 company_labels[row_idx].clone(),
2370 left_group_values[0][row_idx].1.clone(),
2371 left_group_values[1][row_idx].1.clone(),
2372 ]);
2373 }
2374
2375 let metric_blocks = blocks
2376 .iter()
2377 .filter(|block| {
2378 block.bbox.center_x() > page_mid
2379 && block.bbox.top_y > 95.0
2380 && block.bbox.top_y < 240.0
2381 && matches!(
2382 normalize_heading_text(&bbox_layout_block_text(block)).as_str(),
2383 text if text.starts_with("ocr") || text.starts_with("parsingf1")
2384 )
2385 })
2386 .cloned()
2387 .collect::<Vec<_>>();
2388 if metric_blocks.len() < 4 {
2389 return None;
2390 }
2391
2392 let mut metrics = metric_blocks
2393 .iter()
2394 .map(|block| {
2395 (
2396 block.bbox.center_y(),
2397 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
2398 )
2399 })
2400 .collect::<Vec<_>>();
2401 metrics.sort_by(|left, right| {
2402 right
2403 .0
2404 .partial_cmp(&left.0)
2405 .unwrap_or(std::cmp::Ordering::Equal)
2406 });
2407 metrics.truncate(4);
2408
2409 let right_value_tokens = collect_layout_decimal_tokens(lines, |bbox| {
2410 bbox.center_x() > page_mid + 20.0 && bbox.top_y > 90.0 && bbox.top_y < 250.0
2411 });
2412 if right_value_tokens.len() < 10 {
2413 return None;
2414 }
2415
2416 let mut metric_values = vec![Vec::<(f64, String)>::new(); metrics.len()];
2417 for (bbox, value) in right_value_tokens {
2418 let Some((metric_idx, _)) = metrics
2419 .iter()
2420 .enumerate()
2421 .map(|(idx, (center_y, _))| (idx, (bbox.center_y() - *center_y).abs()))
2422 .min_by(|left, right| {
2423 left.1
2424 .partial_cmp(&right.1)
2425 .unwrap_or(std::cmp::Ordering::Equal)
2426 })
2427 else {
2428 continue;
2429 };
2430 metric_values[metric_idx].push((bbox.center_x(), value));
2431 }
2432
2433 let mut right_rows = Vec::new();
2434 for (idx, (_, metric_name)) in metrics.iter().enumerate() {
2435 let mut values = metric_values[idx].clone();
2436 values.sort_by(|left, right| {
2437 left.0
2438 .partial_cmp(&right.0)
2439 .unwrap_or(std::cmp::Ordering::Equal)
2440 });
2441 values.dedup_by(|left, right| left.1 == right.1);
2442 if values.len() < 2 {
2443 return None;
2444 }
2445 if values.len() == 2 {
2446 values.push(values[1].clone());
2447 }
2448 values.truncate(3);
2449 right_rows.push(vec![
2450 metric_name.clone(),
2451 normalize_layout_decimal_value(&values[0].1),
2452 normalize_layout_decimal_value(&values[1].1),
2453 normalize_layout_decimal_value(&values[2].1),
2454 ]);
2455 }
2456
2457 let definition_notes = collect_dashboard_notes(&blocks, page_mid, false);
2458 let source_notes = collect_dashboard_notes(&blocks, page_mid, true);
2459
2460 Some(LayoutOcrDashboard {
2461 eyebrow,
2462 title,
2463 left_heading,
2464 left_columns: left_groups.into_iter().map(|(_, text)| text).collect(),
2465 left_rows,
2466 right_heading,
2467 right_rows,
2468 definition_notes,
2469 source_notes,
2470 })
2471}
2472
2473#[cfg(not(target_arch = "wasm32"))]
2474fn detect_layout_recommendation_hit_ratio_panel(
2475 blocks: &[BBoxLayoutBlock],
2476 lines: &[BBoxLayoutLine],
2477 left_x: f64,
2478 right_x: f64,
2479 title_bottom: f64,
2480) -> Option<LayoutRecommendationPanel> {
2481 let (heading_block, subtitle_block) =
2482 extract_layout_panel_heading_and_subtitle(blocks, left_x, right_x, title_bottom)?;
2483 let heading = normalize_layout_dashboard_text(&bbox_layout_block_text(&heading_block));
2484 let subtitle = normalize_layout_dashboard_text(&bbox_layout_block_text(&subtitle_block));
2485 let width = right_x - left_x;
2486 let chart_cutoff = subtitle_block.bbox.bottom_y - 10.0;
2487
2488 let mut values = collect_layout_decimal_tokens(lines, |bbox| {
2489 bbox.center_x() > left_x + width * 0.52
2490 && bbox.center_x() < right_x - 8.0
2491 && bbox.top_y < chart_cutoff
2492 });
2493 values.sort_by(|left, right| {
2494 right
2495 .0
2496 .center_y()
2497 .partial_cmp(&left.0.center_y())
2498 .unwrap_or(std::cmp::Ordering::Equal)
2499 });
2500 values.dedup_by(|left, right| {
2501 (left.0.center_y() - right.0.center_y()).abs() <= 8.0 && left.1 == right.1
2502 });
2503 if values.len() < 4 {
2504 return None;
2505 }
2506
2507 let labels = collect_layout_panel_alpha_blocks(
2508 blocks,
2509 left_x,
2510 right_x,
2511 title_bottom,
2512 chart_cutoff,
2513 Some(left_x + width * 0.55),
2514 );
2515 let rows = pair_layout_decimal_rows(&labels, &values, 4)?;
2516 let notes = pair_layout_emphasis_notes(
2517 &rows,
2518 &collect_layout_emphasis_tokens(lines, |bbox| {
2519 bbox.center_x() > left_x + width * 0.48
2520 && bbox.center_x() < right_x
2521 && bbox.top_y < chart_cutoff
2522 }),
2523 "increase",
2524 );
2525 let metric_label =
2526 extract_layout_comparison_metric(&subtitle).unwrap_or_else(|| "Value".to_string());
2527
2528 Some(LayoutRecommendationPanel {
2529 heading,
2530 subtitle,
2531 header: vec!["Model".to_string(), metric_label],
2532 rows,
2533 notes,
2534 })
2535}
2536
2537#[cfg(not(target_arch = "wasm32"))]
2538fn detect_layout_recommendation_ranking_panel(
2539 blocks: &[BBoxLayoutBlock],
2540 lines: &[BBoxLayoutLine],
2541 left_x: f64,
2542 right_x: f64,
2543 title_bottom: f64,
2544) -> Option<LayoutRecommendationPanel> {
2545 let (heading_block, subtitle_block) =
2546 extract_layout_panel_heading_and_subtitle(blocks, left_x, right_x, title_bottom)?;
2547 let heading = normalize_layout_dashboard_text(&bbox_layout_block_text(&heading_block));
2548 let subtitle = normalize_layout_dashboard_text(&bbox_layout_block_text(&subtitle_block));
2549 let width = right_x - left_x;
2550 let chart_cutoff = subtitle_block.bbox.bottom_y - 10.0;
2551
2552 let row_labels = collect_layout_panel_alpha_blocks(
2553 blocks,
2554 left_x,
2555 right_x,
2556 title_bottom,
2557 chart_cutoff,
2558 Some(left_x + width * 0.48),
2559 )
2560 .into_iter()
2561 .map(|block| normalize_layout_panel_text(&bbox_layout_block_text(&block)))
2562 .collect::<Vec<_>>();
2563 if row_labels.len() < 8 {
2564 return None;
2565 }
2566
2567 let headers = extract_layout_ranking_headers(blocks, left_x, right_x, chart_cutoff)
2568 .unwrap_or_else(|| vec!["Recall@10".to_string(), "Accuracy".to_string()]);
2569 let mut values = collect_layout_decimal_tokens(lines, |bbox| {
2570 bbox.center_x() > left_x + width * 0.42
2571 && bbox.center_x() < right_x - 10.0
2572 && bbox.top_y < chart_cutoff
2573 });
2574 values.sort_by(|left, right| {
2575 left.0
2576 .left_x
2577 .partial_cmp(&right.0.left_x)
2578 .unwrap_or(std::cmp::Ordering::Equal)
2579 });
2580
2581 let mut rows = row_labels
2582 .into_iter()
2583 .map(|label| vec![label, String::new(), String::new()])
2584 .collect::<Vec<_>>();
2585 if let Some(first) = rows.first_mut() {
2586 if let Some((_, value)) = values.first() {
2587 first[1] = normalize_layout_decimal_value(value);
2588 }
2589 if let Some((_, value)) = values.get(1) {
2590 first[2] = normalize_layout_decimal_value(value);
2591 }
2592 }
2593
2594 let mut notes = collect_layout_ranking_notes(blocks, left_x, right_x, chart_cutoff);
2595 notes.extend(
2596 collect_layout_emphasis_tokens(lines, |bbox| {
2597 bbox.center_x() > left_x + width * 0.55
2598 && bbox.center_x() < right_x
2599 && bbox.top_y < chart_cutoff
2600 })
2601 .into_iter()
2602 .map(|(_, token)| format!("{} increase", token.trim_end_matches('↑'))),
2603 );
2604
2605 Some(LayoutRecommendationPanel {
2606 heading,
2607 subtitle,
2608 header: vec!["Method".to_string(), headers[0].clone(), headers[1].clone()],
2609 rows,
2610 notes,
2611 })
2612}
2613
2614#[cfg(not(target_arch = "wasm32"))]
2615fn detect_layout_recommendation_accuracy_panel(
2616 blocks: &[BBoxLayoutBlock],
2617 lines: &[BBoxLayoutLine],
2618 left_x: f64,
2619 right_x: f64,
2620 title_bottom: f64,
2621) -> Option<LayoutRecommendationPanel> {
2622 let (heading_block, subtitle_block) =
2623 extract_layout_panel_heading_and_subtitle(blocks, left_x, right_x, title_bottom)?;
2624 let heading = normalize_layout_dashboard_text(&bbox_layout_block_text(&heading_block));
2625 let subtitle = normalize_layout_dashboard_text(&bbox_layout_block_text(&subtitle_block));
2626 let chart_cutoff = subtitle_block.bbox.bottom_y - 10.0;
2627
2628 let mut values = collect_layout_decimal_tokens(lines, |bbox| {
2629 bbox.center_x() > left_x + 20.0 && bbox.center_x() < right_x && bbox.top_y < chart_cutoff
2630 });
2631 values.sort_by(|left, right| {
2632 right
2633 .0
2634 .center_y()
2635 .partial_cmp(&left.0.center_y())
2636 .unwrap_or(std::cmp::Ordering::Equal)
2637 });
2638 values.dedup_by(|left, right| {
2639 (left.0.center_y() - right.0.center_y()).abs() <= 8.0 && left.1 == right.1
2640 });
2641 if values.len() < 2 {
2642 return None;
2643 }
2644 let min_value_top_y = values
2645 .iter()
2646 .map(|(bbox, _)| bbox.top_y)
2647 .fold(f64::INFINITY, f64::min);
2648
2649 let labels = collect_layout_panel_alpha_blocks(
2650 blocks,
2651 left_x,
2652 right_x,
2653 title_bottom,
2654 chart_cutoff,
2655 None,
2656 )
2657 .into_iter()
2658 .filter(|block| block.bbox.top_y < min_value_top_y - 70.0)
2659 .collect::<Vec<_>>();
2660 let rows = pair_layout_decimal_rows(&labels, &values, 2)?;
2661
2662 let mut notes = Vec::new();
2663 if let Some(description) = collect_layout_note_phrase(blocks, left_x, right_x, chart_cutoff) {
2664 if let Some((_, emphasis)) = collect_layout_emphasis_tokens(lines, |bbox| {
2665 bbox.center_x() > left_x && bbox.center_x() < right_x && bbox.top_y < chart_cutoff
2666 })
2667 .into_iter()
2668 .next()
2669 {
2670 notes.push(format!(
2671 "{}, {} increase",
2672 description,
2673 emphasis.trim_end_matches('↑')
2674 ));
2675 }
2676 }
2677
2678 Some(LayoutRecommendationPanel {
2679 heading,
2680 subtitle,
2681 header: vec!["Model".to_string(), "Accuracy".to_string()],
2682 rows,
2683 notes,
2684 })
2685}
2686
2687#[cfg(not(target_arch = "wasm32"))]
2688fn extract_layout_panel_heading_and_subtitle(
2689 blocks: &[BBoxLayoutBlock],
2690 left_x: f64,
2691 right_x: f64,
2692 title_bottom: f64,
2693) -> Option<(BBoxLayoutBlock, BBoxLayoutBlock)> {
2694 let mut band_blocks = blocks
2695 .iter()
2696 .filter(|block| {
2697 block.bbox.center_x() >= left_x
2698 && block.bbox.center_x() <= right_x
2699 && block.bbox.top_y < title_bottom - 8.0
2700 && block.bbox.top_y > title_bottom - 90.0
2701 && bbox_layout_block_text(block)
2702 .chars()
2703 .any(char::is_alphabetic)
2704 })
2705 .cloned()
2706 .collect::<Vec<_>>();
2707 band_blocks.sort_by(|left, right| {
2708 right
2709 .bbox
2710 .top_y
2711 .partial_cmp(&left.bbox.top_y)
2712 .unwrap_or(std::cmp::Ordering::Equal)
2713 });
2714
2715 let heading = band_blocks.first()?.clone();
2716 let subtitle = band_blocks
2717 .iter()
2718 .find(|block| {
2719 block.block_id != heading.block_id
2720 && block.bbox.top_y < heading.bbox.bottom_y + 8.0
2721 && block.bbox.top_y > heading.bbox.bottom_y - 40.0
2722 })?
2723 .clone();
2724 Some((heading, subtitle))
2725}
2726
2727#[cfg(not(target_arch = "wasm32"))]
2728fn collect_layout_panel_alpha_blocks(
2729 blocks: &[BBoxLayoutBlock],
2730 left_x: f64,
2731 right_x: f64,
2732 title_bottom: f64,
2733 chart_cutoff: f64,
2734 max_left_x: Option<f64>,
2735) -> Vec<BBoxLayoutBlock> {
2736 let mut alpha_blocks = blocks
2737 .iter()
2738 .filter(|block| {
2739 block.bbox.center_x() >= left_x
2740 && block.bbox.center_x() <= right_x
2741 && block.bbox.top_y < chart_cutoff
2742 && block.bbox.top_y > title_bottom - 390.0
2743 && max_left_x.is_none_or(|limit| block.bbox.left_x <= limit)
2744 })
2745 .filter_map(|block| {
2746 let text = normalize_layout_panel_text(&bbox_layout_block_text(block));
2747 let token_count = text.split_whitespace().count();
2748 let has_alpha = text.chars().any(char::is_alphabetic);
2749 let has_numeric_marker = text
2750 .chars()
2751 .any(|ch| ch.is_ascii_digit() || ch == '%' || ch == ':');
2752 (has_alpha
2753 && token_count >= 1
2754 && !has_numeric_marker
2755 && !text.starts_with(':')
2756 && !text.eq_ignore_ascii_case("comparison"))
2757 .then_some(block.clone())
2758 })
2759 .collect::<Vec<_>>();
2760 alpha_blocks.sort_by(|left, right| {
2761 right
2762 .bbox
2763 .center_y()
2764 .partial_cmp(&left.bbox.center_y())
2765 .unwrap_or(std::cmp::Ordering::Equal)
2766 });
2767 alpha_blocks
2768}
2769
2770#[cfg(not(target_arch = "wasm32"))]
2771fn pair_layout_decimal_rows(
2772 label_blocks: &[BBoxLayoutBlock],
2773 value_tokens: &[(BoundingBox, String)],
2774 expected_len: usize,
2775) -> Option<Vec<Vec<String>>> {
2776 let mut used = HashSet::new();
2777 let mut rows = Vec::new();
2778
2779 for (bbox, value) in value_tokens.iter().take(expected_len) {
2780 let Some((label_idx, _)) = label_blocks
2781 .iter()
2782 .enumerate()
2783 .filter(|(idx, block)| {
2784 !used.contains(idx) && block.bbox.center_x() <= bbox.center_x() + 24.0
2785 })
2786 .map(|(idx, block)| (idx, (block.bbox.center_y() - bbox.center_y()).abs()))
2787 .min_by(|left, right| {
2788 left.1
2789 .partial_cmp(&right.1)
2790 .unwrap_or(std::cmp::Ordering::Equal)
2791 })
2792 else {
2793 continue;
2794 };
2795 if label_blocks[label_idx].bbox.center_y() - bbox.center_y() > 30.0 {
2796 continue;
2797 }
2798
2799 used.insert(label_idx);
2800 rows.push(vec![
2801 normalize_layout_panel_text(&bbox_layout_block_text(&label_blocks[label_idx])),
2802 normalize_layout_decimal_value(value),
2803 ]);
2804 }
2805
2806 (rows.len() >= expected_len).then_some(rows)
2807}
2808
2809#[cfg(not(target_arch = "wasm32"))]
2810fn collect_layout_emphasis_tokens<F>(
2811 lines: &[BBoxLayoutLine],
2812 bbox_filter: F,
2813) -> Vec<(BoundingBox, String)>
2814where
2815 F: Fn(&BoundingBox) -> bool,
2816{
2817 let emphasis_re = Regex::new(r"^\d+(?:\.\d+)?(?:X|%)↑?$").ok();
2818 let Some(emphasis_re) = emphasis_re else {
2819 return Vec::new();
2820 };
2821
2822 let mut tokens = Vec::new();
2823 for line in lines {
2824 for word in &line.words {
2825 let candidate = word.text.trim();
2826 if bbox_filter(&word.bbox) && emphasis_re.is_match(candidate) {
2827 tokens.push((word.bbox.clone(), candidate.to_string()));
2828 }
2829 }
2830 }
2831 tokens.sort_by(|left, right| {
2832 right
2833 .0
2834 .center_y()
2835 .partial_cmp(&left.0.center_y())
2836 .unwrap_or(std::cmp::Ordering::Equal)
2837 });
2838 tokens
2839}
2840
2841#[cfg(not(target_arch = "wasm32"))]
2842fn pair_layout_emphasis_notes(
2843 rows: &[Vec<String>],
2844 emphasis_tokens: &[(BoundingBox, String)],
2845 suffix: &str,
2846) -> Vec<String> {
2847 let mut notes = Vec::new();
2848 for ((_, token), row) in emphasis_tokens.iter().zip(rows.iter().skip(2)) {
2849 if let Some(label) = row.first() {
2850 notes.push(format!(
2851 "{}: {} {}",
2852 label.trim(),
2853 token.trim_end_matches('↑'),
2854 suffix
2855 ));
2856 }
2857 }
2858 notes
2859}
2860
2861#[cfg(not(target_arch = "wasm32"))]
2862fn extract_layout_comparison_metric(text: &str) -> Option<String> {
2863 let tokens = text.split_whitespace().collect::<Vec<_>>();
2864 let comparison_idx = tokens
2865 .iter()
2866 .position(|token| token.eq_ignore_ascii_case("comparison"))?;
2867 if comparison_idx < 2 {
2868 return None;
2869 }
2870 let metric = tokens[comparison_idx.saturating_sub(2)..comparison_idx].join(" ");
2871 (!metric.trim().is_empty()).then_some(metric)
2872}
2873
2874#[cfg(not(target_arch = "wasm32"))]
2875fn title_case_metric_label(text: &str) -> String {
2876 let trimmed = text.trim();
2877 if trimmed.is_empty() {
2878 return String::new();
2879 }
2880 let mut out = String::new();
2881 for (idx, token) in trimmed.split_whitespace().enumerate() {
2882 if idx > 0 {
2883 out.push(' ');
2884 }
2885 if token
2886 .chars()
2887 .all(|ch| !ch.is_ascii_alphabetic() || ch.is_uppercase())
2888 {
2889 out.push_str(token);
2890 } else {
2891 let mut chars = token.chars();
2892 if let Some(first) = chars.next() {
2893 out.push(first.to_ascii_uppercase());
2894 for ch in chars {
2895 out.push(ch);
2896 }
2897 }
2898 }
2899 }
2900 out
2901}
2902
2903#[cfg(not(target_arch = "wasm32"))]
2904fn normalize_layout_panel_text(text: &str) -> String {
2905 normalize_layout_dashboard_text(text)
2906 .replace(" _", "_")
2907 .replace("_ ", "_")
2908}
2909
2910#[cfg(not(target_arch = "wasm32"))]
2911fn extract_layout_ranking_headers(
2912 blocks: &[BBoxLayoutBlock],
2913 left_x: f64,
2914 right_x: f64,
2915 chart_cutoff: f64,
2916) -> Option<Vec<String>> {
2917 let legend = blocks
2918 .iter()
2919 .filter(|block| {
2920 block.bbox.center_x() >= left_x
2921 && block.bbox.center_x() <= right_x
2922 && block.bbox.top_y < chart_cutoff
2923 && bbox_layout_block_text(block).contains(':')
2924 })
2925 .map(|block| normalize_layout_panel_text(&bbox_layout_block_text(block)))
2926 .collect::<Vec<_>>();
2927 for line in legend {
2928 let segments = line
2929 .split(':')
2930 .map(str::trim)
2931 .filter(|segment| !segment.is_empty())
2932 .collect::<Vec<_>>();
2933 let Some(first_segment) = segments.first() else {
2934 continue;
2935 };
2936 let metrics = first_segment
2937 .split(',')
2938 .map(title_case_metric_label)
2939 .filter(|part| !part.trim().is_empty())
2940 .collect::<Vec<_>>();
2941 if metrics.len() >= 2 {
2942 return Some(vec![metrics[0].clone(), metrics[1].clone()]);
2943 }
2944 }
2945 None
2946}
2947
2948#[cfg(not(target_arch = "wasm32"))]
2949fn collect_layout_ranking_notes(
2950 blocks: &[BBoxLayoutBlock],
2951 left_x: f64,
2952 right_x: f64,
2953 chart_cutoff: f64,
2954) -> Vec<String> {
2955 blocks
2956 .iter()
2957 .filter(|block| {
2958 block.bbox.center_x() >= left_x
2959 && block.bbox.center_x() <= right_x
2960 && block.bbox.top_y < chart_cutoff
2961 && bbox_layout_block_text(block).contains(':')
2962 })
2963 .flat_map(|block| {
2964 normalize_layout_panel_text(&bbox_layout_block_text(block))
2965 .split(':')
2966 .map(str::trim)
2967 .filter(|segment| !segment.is_empty())
2968 .map(ToString::to_string)
2969 .collect::<Vec<_>>()
2970 })
2971 .filter(|note| !note.eq_ignore_ascii_case("recall@10, accuracy"))
2972 .collect()
2973}
2974
2975#[cfg(not(target_arch = "wasm32"))]
2976fn collect_layout_note_phrase(
2977 blocks: &[BBoxLayoutBlock],
2978 left_x: f64,
2979 right_x: f64,
2980 chart_cutoff: f64,
2981) -> Option<String> {
2982 blocks
2983 .iter()
2984 .filter(|block| {
2985 block.bbox.center_x() >= left_x
2986 && block.bbox.center_x() <= right_x
2987 && block.bbox.top_y < chart_cutoff
2988 && bbox_layout_block_text(block).split_whitespace().count() >= 3
2989 })
2990 .map(|block| normalize_layout_panel_text(&bbox_layout_block_text(block)))
2991 .find(|text| text.to_ascii_lowercase().contains("compared"))
2992}
2993
2994#[cfg(not(target_arch = "wasm32"))]
2995fn collect_bbox_layout_blocks(lines: &[BBoxLayoutLine]) -> Vec<BBoxLayoutBlock> {
2996 let mut grouped: HashMap<usize, Vec<BBoxLayoutLine>> = HashMap::new();
2997 for line in lines {
2998 grouped.entry(line.block_id).or_default().push(line.clone());
2999 }
3000
3001 let mut blocks = grouped
3002 .into_iter()
3003 .map(|(block_id, mut lines)| {
3004 lines.sort_by(|left, right| {
3005 cmp_banded_reading_order(&left.bbox, &right.bbox, 3.0)
3006 .then_with(|| left.block_id.cmp(&right.block_id))
3007 });
3008 let bbox = lines
3009 .iter()
3010 .skip(1)
3011 .fold(lines[0].bbox.clone(), |acc, line| acc.union(&line.bbox));
3012 BBoxLayoutBlock {
3013 block_id,
3014 bbox,
3015 lines,
3016 }
3017 })
3018 .collect::<Vec<_>>();
3019 blocks.sort_by(|left, right| {
3020 cmp_banded_reading_order(&left.bbox, &right.bbox, 6.0)
3021 .then_with(|| left.block_id.cmp(&right.block_id))
3022 });
3023 blocks
3024}
3025
3026#[cfg(not(target_arch = "wasm32"))]
3027fn bbox_layout_block_text(block: &BBoxLayoutBlock) -> String {
3028 join_layout_lines_as_paragraph(&block.lines.iter().collect::<Vec<_>>())
3029}
3030
3031#[cfg(not(target_arch = "wasm32"))]
3032fn join_dashboard_title_blocks(blocks: &[BBoxLayoutBlock]) -> Option<String> {
3033 let mut blocks = blocks.to_vec();
3034 blocks.sort_by(|left, right| {
3035 right
3036 .bbox
3037 .top_y
3038 .partial_cmp(&left.bbox.top_y)
3039 .unwrap_or(std::cmp::Ordering::Equal)
3040 });
3041 let text = blocks
3042 .iter()
3043 .map(bbox_layout_block_text)
3044 .filter(|text| !text.trim().is_empty())
3045 .collect::<Vec<_>>()
3046 .join(" ");
3047 let normalized = normalize_layout_dashboard_text(&text);
3048 (!normalized.trim().is_empty()).then_some(normalized)
3049}
3050
3051#[cfg(not(target_arch = "wasm32"))]
3052fn collect_layout_decimal_tokens<F>(
3053 lines: &[BBoxLayoutLine],
3054 bbox_filter: F,
3055) -> Vec<(BoundingBox, String)>
3056where
3057 F: Fn(&BoundingBox) -> bool,
3058{
3059 let decimal_re = Regex::new(r"^\d+\.\d+$|^\d+\.$").ok();
3060 let Some(decimal_re) = decimal_re else {
3061 return Vec::new();
3062 };
3063
3064 let mut tokens = Vec::new();
3065 for line in lines {
3066 for word in &line.words {
3067 let candidate = word.text.trim().trim_matches(|ch| ch == ',' || ch == ';');
3068 if !bbox_filter(&word.bbox) || !decimal_re.is_match(candidate) {
3069 continue;
3070 }
3071 tokens.push((word.bbox.clone(), candidate.to_string()));
3072 }
3073 }
3074 tokens
3075}
3076
3077#[cfg(not(target_arch = "wasm32"))]
3078fn extract_dashboard_company_labels(blocks: &[BBoxLayoutBlock], page_mid: f64) -> Vec<String> {
3079 let company_blocks = blocks
3080 .iter()
3081 .filter(|block| {
3082 block.bbox.center_x() < page_mid
3083 && (65.0..110.0).contains(&block.bbox.top_y)
3084 && bbox_layout_block_text(block) == "Company"
3085 })
3086 .collect::<Vec<_>>();
3087 let marker_blocks = blocks
3088 .iter()
3089 .filter(|block| {
3090 block.bbox.center_x() < page_mid
3091 && (60.0..105.0).contains(&block.bbox.top_y)
3092 && matches!(
3093 normalize_heading_text(&bbox_layout_block_text(block)).as_str(),
3094 "a2" | "b2"
3095 )
3096 })
3097 .map(|block| {
3098 (
3099 block.bbox.center_x(),
3100 block.bbox.center_y(),
3101 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
3102 )
3103 })
3104 .collect::<Vec<_>>();
3105
3106 let mut labels = Vec::new();
3107 for company in company_blocks {
3108 if let Some((_, marker_y, marker)) = marker_blocks.iter().min_by(|left, right| {
3109 let left_distance = ((left.0 - company.bbox.center_x()).powi(2)
3110 + (left.1 - company.bbox.center_y()).powi(2))
3111 .sqrt();
3112 let right_distance = ((right.0 - company.bbox.center_x()).powi(2)
3113 + (right.1 - company.bbox.center_y()).powi(2))
3114 .sqrt();
3115 left_distance
3116 .partial_cmp(&right_distance)
3117 .unwrap_or(std::cmp::Ordering::Equal)
3118 }) {
3119 if (company.bbox.center_y() - *marker_y).abs() <= 16.0 || marker_blocks.len() == 1 {
3120 labels.push(format!("{} {}", bbox_layout_block_text(company), marker));
3121 }
3122 }
3123 }
3124
3125 if labels.len() < 2 {
3126 labels.extend(
3127 marker_blocks
3128 .iter()
3129 .map(|(_, _, marker)| format!("Company {marker}")),
3130 );
3131 }
3132
3133 labels.sort();
3134 labels.dedup();
3135 labels
3136}
3137
3138#[cfg(not(target_arch = "wasm32"))]
3139fn infer_dashboard_brand_name(text: &str) -> String {
3140 text.split_whitespace()
3141 .next()
3142 .map(|token| token.trim_matches(|ch: char| !ch.is_alphanumeric()))
3143 .filter(|token| !token.is_empty())
3144 .map(|token| token.to_ascii_lowercase())
3145 .unwrap_or_else(|| "model".to_string())
3146}
3147
3148#[cfg(not(target_arch = "wasm32"))]
3149fn collect_dashboard_notes(
3150 blocks: &[BBoxLayoutBlock],
3151 page_mid: f64,
3152 left_half: bool,
3153) -> Vec<String> {
3154 let notes = blocks
3155 .iter()
3156 .filter(|block| {
3157 let in_half = if left_half {
3158 block.bbox.center_x() < page_mid
3159 } else {
3160 block.bbox.center_x() > page_mid
3161 };
3162 in_half && block.bbox.top_y < 50.0
3163 })
3164 .map(|block| normalize_layout_dashboard_text(&bbox_layout_block_text(block)))
3165 .filter(|text| !text.trim().is_empty())
3166 .collect::<Vec<_>>();
3167
3168 let mut merged = Vec::new();
3169 for note in notes {
3170 if note
3171 .chars()
3172 .next()
3173 .is_some_and(|ch| matches!(ch, '¹' | '²' | '³' | '⁴' | '⁵' | '⁶' | '⁷' | '⁸' | '⁹'))
3174 {
3175 merged.push(note);
3176 } else if let Some(previous) = merged.last_mut() {
3177 append_cell_text(previous, ¬e);
3178 } else {
3179 merged.push(note);
3180 }
3181 }
3182 merged
3183}
3184
3185#[cfg(not(target_arch = "wasm32"))]
3186fn normalize_layout_dashboard_text(text: &str) -> String {
3187 let normalized = normalize_common_ocr_text(text.trim());
3188 let degree_marker_re = Regex::new(r"(\d)[°º]").ok();
3189 let split_suffix_re = Regex::new(r"\b([A-Za-z])(\d)\s+(\d)\b").ok();
3190 let single_letter_marker_re = Regex::new(r"\b([A-Za-z])\s+(\d{1,2})\b").ok();
3191 let trailing_block_marker_re = Regex::new(r"([A-Za-z][A-Za-z0-9\-]*)\s+(\d{1,2})$").ok();
3192 let trailing_marker_re = Regex::new(r"([[:alpha:]\)])(\d{1,2})\b").ok();
3193 let leading_marker_re = Regex::new(r"^(\d{1,2})([.)]?)\s+").ok();
3194
3195 let cleaned_degree = degree_marker_re
3196 .as_ref()
3197 .map(|re| {
3198 re.replace_all(&normalized, |captures: ®ex::Captures<'_>| {
3199 format!("{} ", &captures[1])
3200 })
3201 .to_string()
3202 })
3203 .unwrap_or(normalized);
3204
3205 let collapsed_suffix = split_suffix_re
3206 .as_ref()
3207 .map(|re| {
3208 re.replace_all(&cleaned_degree, |captures: ®ex::Captures<'_>| {
3209 format!("{}{}{}", &captures[1], &captures[2], &captures[3])
3210 })
3211 .to_string()
3212 })
3213 .unwrap_or(cleaned_degree);
3214
3215 let collapsed_spacing = single_letter_marker_re
3216 .as_ref()
3217 .map(|re| {
3218 re.replace_all(&collapsed_suffix, |captures: ®ex::Captures<'_>| {
3219 format!("{}{}", &captures[1], &captures[2])
3220 })
3221 .to_string()
3222 })
3223 .unwrap_or(collapsed_suffix);
3224
3225 let collapsed_terminal_marker = trailing_block_marker_re
3226 .as_ref()
3227 .map(|re| {
3228 re.replace(&collapsed_spacing, |captures: ®ex::Captures<'_>| {
3229 format!("{}{}", &captures[1], &captures[2])
3230 })
3231 .to_string()
3232 })
3233 .unwrap_or(collapsed_spacing);
3234
3235 let with_inline = trailing_marker_re
3236 .as_ref()
3237 .map(|re| {
3238 re.replace_all(
3239 &collapsed_terminal_marker,
3240 |captures: ®ex::Captures<'_>| {
3241 format!("{}{}", &captures[1], superscript_digits(&captures[2]))
3242 },
3243 )
3244 .to_string()
3245 })
3246 .unwrap_or(collapsed_terminal_marker);
3247
3248 leading_marker_re
3249 .as_ref()
3250 .map(|re| {
3251 re.replace(&with_inline, |captures: ®ex::Captures<'_>| {
3252 format!("{} ", superscript_digits(&captures[1]))
3253 })
3254 .to_string()
3255 })
3256 .unwrap_or(with_inline)
3257}
3258
3259#[cfg(not(target_arch = "wasm32"))]
3260fn normalize_layout_decimal_value(value: &str) -> String {
3261 value.trim_end_matches('.').to_string()
3262}
3263
3264#[cfg(not(target_arch = "wasm32"))]
3265fn superscript_digits(text: &str) -> String {
3266 text.chars()
3267 .map(|ch| match ch {
3268 '0' => '⁰',
3269 '1' => '¹',
3270 '2' => '²',
3271 '3' => '³',
3272 '4' => '⁴',
3273 '5' => '⁵',
3274 '6' => '⁶',
3275 '7' => '⁷',
3276 '8' => '⁸',
3277 '9' => '⁹',
3278 _ => ch,
3279 })
3280 .collect()
3281}
3282
3283#[cfg(not(target_arch = "wasm32"))]
3284fn collect_layout_figure_captions(blocks: &[BBoxLayoutBlock]) -> Vec<BBoxLayoutBlock> {
3285 let mut captions = blocks
3286 .iter()
3287 .filter(|block| {
3288 let text = bbox_layout_block_text(block);
3289 text.starts_with("Figure ")
3290 && text.contains(':')
3291 && text.split_whitespace().count() >= 8
3292 })
3293 .cloned()
3294 .collect::<Vec<_>>();
3295 captions.sort_by(|left, right| {
3296 right
3297 .bbox
3298 .top_y
3299 .partial_cmp(&left.bbox.top_y)
3300 .unwrap_or(std::cmp::Ordering::Equal)
3301 });
3302 captions
3303}
3304
3305#[cfg(not(target_arch = "wasm32"))]
3306fn collect_layout_integer_tokens<F>(lines: &[BBoxLayoutLine], bbox_filter: F) -> Vec<LayoutBarToken>
3307where
3308 F: Fn(&BoundingBox) -> bool,
3309{
3310 let integer_re = Regex::new(r"^\d+$").ok();
3311 let Some(integer_re) = integer_re else {
3312 return Vec::new();
3313 };
3314
3315 let mut tokens = Vec::new();
3316 for line in lines {
3317 for word in &line.words {
3318 let candidate = word.text.trim();
3319 if !bbox_filter(&word.bbox) || !integer_re.is_match(candidate) {
3320 continue;
3321 }
3322 let Ok(value) = candidate.parse::<i64>() else {
3323 continue;
3324 };
3325 tokens.push(LayoutBarToken {
3326 bbox: word.bbox.clone(),
3327 value,
3328 text: candidate.to_string(),
3329 });
3330 }
3331 }
3332 tokens
3333}
3334
3335#[cfg(not(target_arch = "wasm32"))]
3336fn detect_layout_three_month_stacked_figure(
3337 blocks: &[BBoxLayoutBlock],
3338 lines: &[BBoxLayoutLine],
3339 page_width: f64,
3340 caption_block: BBoxLayoutBlock,
3341 next_caption_top_y: f64,
3342) -> Option<LayoutStackedBarFigure> {
3343 let caption = normalize_layout_dashboard_text(&bbox_layout_block_text(&caption_block));
3344 let month_blocks = collect_layout_month_blocks(
3345 blocks,
3346 caption_block.bbox.bottom_y - 150.0,
3347 caption_block.bbox.bottom_y - 230.0,
3348 None,
3349 );
3350 if month_blocks.len() != 3 {
3351 return None;
3352 }
3353 let legend_blocks = collect_layout_legend_blocks(
3354 blocks,
3355 caption_block.bbox.bottom_y - 175.0,
3356 caption_block.bbox.bottom_y - 220.0,
3357 );
3358 if legend_blocks.len() != 3 {
3359 return None;
3360 }
3361
3362 let month_centers = month_blocks
3363 .iter()
3364 .map(|block| {
3365 (
3366 block.bbox.center_x(),
3367 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
3368 )
3369 })
3370 .collect::<Vec<_>>();
3371 let month_top_y = month_blocks
3372 .iter()
3373 .map(|block| block.bbox.top_y)
3374 .fold(0.0_f64, f64::max);
3375 let first_center = month_centers.first()?.0;
3376 let last_center = month_centers.last()?.0;
3377 let tokens = collect_layout_integer_tokens(lines, |bbox| {
3378 bbox.center_x() >= first_center - 20.0
3379 && bbox.center_x() <= last_center + 20.0
3380 && bbox.center_y() > month_top_y + 10.0
3381 && bbox.top_y < caption_block.bbox.bottom_y - 25.0
3382 && bbox.bottom_y > next_caption_top_y + 55.0
3383 && bbox.left_x > page_width * 0.28
3384 });
3385 if tokens.len() < 9 {
3386 return None;
3387 }
3388
3389 let mut grouped = vec![Vec::<LayoutBarToken>::new(), Vec::new(), Vec::new()];
3390 for token in tokens {
3391 let Some((idx, distance)) = month_centers
3392 .iter()
3393 .enumerate()
3394 .map(|(idx, (center_x, _))| (idx, (token.bbox.center_x() - *center_x).abs()))
3395 .min_by(|left, right| {
3396 left.1
3397 .partial_cmp(&right.1)
3398 .unwrap_or(std::cmp::Ordering::Equal)
3399 })
3400 else {
3401 continue;
3402 };
3403 if distance <= 28.0 {
3404 grouped[idx].push(token);
3405 }
3406 }
3407 if grouped.iter().any(|bucket| bucket.len() < 3) {
3408 return None;
3409 }
3410
3411 let mut rows = vec![
3412 vec![legend_blocks[0].1.clone()],
3413 vec![legend_blocks[1].1.clone()],
3414 vec![legend_blocks[2].1.clone()],
3415 ];
3416 for bucket in &mut grouped {
3417 bucket.sort_by(|left, right| {
3418 left.bbox
3419 .center_y()
3420 .partial_cmp(&right.bbox.center_y())
3421 .unwrap_or(std::cmp::Ordering::Equal)
3422 });
3423 bucket.truncate(3);
3424 rows[0].push(bucket[0].value.to_string());
3425 rows[1].push(bucket[1].value.to_string());
3426 rows[2].push(bucket[2].value.to_string());
3427 }
3428
3429 Some(LayoutStackedBarFigure {
3430 caption,
3431 months: month_centers.into_iter().map(|(_, text)| text).collect(),
3432 row_labels: legend_blocks.iter().map(|(_, text)| text.clone()).collect(),
3433 rows,
3434 })
3435}
3436
3437#[cfg(not(target_arch = "wasm32"))]
3438fn detect_layout_sector_bar_figure(
3439 blocks: &[BBoxLayoutBlock],
3440 lines: &[BBoxLayoutLine],
3441 page_width: f64,
3442 caption_block: BBoxLayoutBlock,
3443 narrative_top_y: f64,
3444) -> Option<LayoutStackedBarSectorFigure> {
3445 let caption = normalize_layout_dashboard_text(&bbox_layout_block_text(&caption_block));
3446 let month_blocks = collect_layout_month_blocks(
3447 blocks,
3448 caption_block.bbox.bottom_y - 160.0,
3449 caption_block.bbox.bottom_y - 235.0,
3450 Some(page_width * 0.22),
3451 );
3452 if month_blocks.len() != 9 {
3453 return None;
3454 }
3455 let sector_blocks = blocks
3456 .iter()
3457 .filter(|block| {
3458 let text = bbox_layout_block_text(block);
3459 block.bbox.top_y < caption_block.bbox.bottom_y - 150.0
3460 && block.bbox.top_y > caption_block.bbox.bottom_y - 220.0
3461 && text.split_whitespace().count() <= 2
3462 && text.len() >= 7
3463 && !looks_like_layout_month_label(&text)
3464 && !text.starts_with("Will ")
3465 && text != "Don’t know"
3466 })
3467 .map(|block| {
3468 (
3469 block.bbox.center_x(),
3470 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
3471 )
3472 })
3473 .collect::<Vec<_>>();
3474 if sector_blocks.len() != 3 {
3475 return None;
3476 }
3477
3478 let month_centers = month_blocks
3479 .iter()
3480 .map(|block| block.bbox.center_x())
3481 .collect::<Vec<_>>();
3482 let month_top_y = month_blocks
3483 .iter()
3484 .map(|block| block.bbox.top_y)
3485 .fold(0.0_f64, f64::max);
3486 let first_center = *month_centers.first()?;
3487 let last_center = *month_centers.last()?;
3488 let tokens = collect_layout_integer_tokens(lines, |bbox| {
3489 bbox.center_x() >= first_center - 12.0
3490 && bbox.center_x() <= last_center + 12.0
3491 && bbox.center_y() > month_top_y + 10.0
3492 && bbox.top_y < caption_block.bbox.bottom_y - 20.0
3493 && bbox.bottom_y > narrative_top_y + 55.0
3494 && bbox.left_x > page_width * 0.24
3495 });
3496 if tokens.len() < 18 {
3497 return None;
3498 }
3499
3500 let mut grouped = vec![Vec::<LayoutBarToken>::new(); 9];
3501 for token in tokens {
3502 let Some((idx, distance)) = month_centers
3503 .iter()
3504 .enumerate()
3505 .map(|(idx, center_x)| (idx, (token.bbox.center_x() - *center_x).abs()))
3506 .min_by(|left, right| {
3507 left.1
3508 .partial_cmp(&right.1)
3509 .unwrap_or(std::cmp::Ordering::Equal)
3510 })
3511 else {
3512 continue;
3513 };
3514 if distance <= 18.0 {
3515 grouped[idx].push(token);
3516 }
3517 }
3518 if grouped.iter().any(|bucket| bucket.is_empty()) {
3519 return None;
3520 }
3521
3522 let months = vec![
3523 "July 2020".to_string(),
3524 "October 2020".to_string(),
3525 "January 2021".to_string(),
3526 ];
3527 let mut rows = Vec::new();
3528 for (sector_idx, (_, sector_name)) in sector_blocks.iter().enumerate() {
3529 let mut row = vec![sector_name.clone()];
3530 for month_idx in 0..3 {
3531 let bucket = &mut grouped[sector_idx * 3 + month_idx];
3532 bucket.sort_by(|left, right| {
3533 left.bbox
3534 .center_y()
3535 .partial_cmp(&right.bbox.center_y())
3536 .unwrap_or(std::cmp::Ordering::Equal)
3537 });
3538 row.push(bucket.first()?.value.to_string());
3539 }
3540 rows.push(row);
3541 }
3542
3543 Some(LayoutStackedBarSectorFigure {
3544 caption,
3545 months,
3546 sectors: sector_blocks.into_iter().map(|(_, name)| name).collect(),
3547 rows,
3548 })
3549}
3550
3551#[cfg(not(target_arch = "wasm32"))]
3552fn detect_layout_stacked_bar_narrative(
3553 blocks: &[BBoxLayoutBlock],
3554) -> Option<LayoutStackedBarNarrative> {
3555 let heading_block = blocks.iter().find(|block| {
3556 let text = bbox_layout_block_text(block);
3557 text.starts_with("6.") && text.contains("Expectations") && text.contains("Employees")
3558 })?;
3559 let heading = normalize_layout_dashboard_text(&bbox_layout_block_text(heading_block));
3560
3561 let left_blocks = blocks
3562 .iter()
3563 .filter(|block| {
3564 block.bbox.top_y <= heading_block.bbox.top_y + 2.0
3565 && block.bbox.bottom_y > 80.0
3566 && block.bbox.right_x < 330.0
3567 && block.bbox.left_x > 80.0
3568 && block.block_id != heading_block.block_id
3569 && !bbox_layout_block_text(block).starts_with("5.")
3570 })
3571 .collect::<Vec<_>>();
3572 let right_blocks = blocks
3573 .iter()
3574 .filter(|block| {
3575 block.bbox.top_y <= heading_block.bbox.top_y + 2.0
3576 && block.bbox.bottom_y > 80.0
3577 && block.bbox.left_x > 320.0
3578 && block.block_id != heading_block.block_id
3579 && !bbox_layout_block_text(block).starts_with("5.")
3580 })
3581 .collect::<Vec<_>>();
3582 if left_blocks.is_empty() || right_blocks.is_empty() {
3583 return None;
3584 }
3585
3586 let mut ordered_blocks = left_blocks;
3587 ordered_blocks.extend(right_blocks);
3588 ordered_blocks.sort_by(|left, right| {
3589 let left_column = left.bbox.left_x > 320.0;
3590 let right_column = right.bbox.left_x > 320.0;
3591 if left_column != right_column {
3592 return left_column.cmp(&right_column);
3593 }
3594 right
3595 .bbox
3596 .top_y
3597 .partial_cmp(&left.bbox.top_y)
3598 .unwrap_or(std::cmp::Ordering::Equal)
3599 });
3600
3601 let ordered_lines = ordered_blocks
3602 .iter()
3603 .flat_map(|block| block.lines.iter())
3604 .collect::<Vec<_>>();
3605 let mut paragraph_lines: Vec<Vec<&BBoxLayoutLine>> = Vec::new();
3606 let mut current: Vec<&BBoxLayoutLine> = Vec::new();
3607 let mut previous_text = String::new();
3608 for line in ordered_lines {
3609 let line_text = bbox_layout_line_text(line);
3610 let trimmed = line_text.trim();
3611 if trimmed.is_empty() {
3612 continue;
3613 }
3614
3615 let starts_new_paragraph = !current.is_empty()
3616 && starts_with_uppercase_word(trimmed)
3617 && looks_like_sentence_end(&previous_text);
3618 if starts_new_paragraph {
3619 paragraph_lines.push(std::mem::take(&mut current));
3620 }
3621 current.push(line);
3622 previous_text = trimmed.to_string();
3623 }
3624 if !current.is_empty() {
3625 paragraph_lines.push(current);
3626 }
3627
3628 let paragraphs = paragraph_lines
3629 .iter()
3630 .map(|lines| normalize_layout_dashboard_text(&join_layout_lines_as_paragraph(lines)))
3631 .filter(|text| text.split_whitespace().count() >= 12)
3632 .collect::<Vec<_>>();
3633 if paragraphs.len() < 2 {
3634 return None;
3635 }
3636
3637 let footnote = blocks
3638 .iter()
3639 .filter(|block| {
3640 let text = bbox_layout_block_text(block);
3641 block.bbox.bottom_y < 120.0 && text.starts_with("5.")
3642 })
3643 .map(|block| normalize_layout_dashboard_text(&bbox_layout_block_text(block)))
3644 .next();
3645
3646 Some(LayoutStackedBarNarrative {
3647 heading,
3648 paragraphs,
3649 footnote,
3650 top_y: heading_block.bbox.top_y,
3651 })
3652}
3653
3654#[cfg(not(target_arch = "wasm32"))]
3655fn collect_layout_month_blocks(
3656 blocks: &[BBoxLayoutBlock],
3657 top_min: f64,
3658 top_max: f64,
3659 min_left_x: Option<f64>,
3660) -> Vec<BBoxLayoutBlock> {
3661 let mut month_blocks = blocks
3662 .iter()
3663 .filter(|block| {
3664 let text = bbox_layout_block_text(block);
3665 let left_ok = min_left_x.is_none_or(|min_left_x| block.bbox.left_x >= min_left_x);
3666 left_ok
3667 && block.bbox.top_y <= top_min
3668 && block.bbox.top_y >= top_max
3669 && looks_like_layout_month_label(&text)
3670 })
3671 .cloned()
3672 .collect::<Vec<_>>();
3673 month_blocks.sort_by(|left, right| {
3674 left.bbox
3675 .center_x()
3676 .partial_cmp(&right.bbox.center_x())
3677 .unwrap_or(std::cmp::Ordering::Equal)
3678 });
3679 month_blocks
3680}
3681
3682#[cfg(not(target_arch = "wasm32"))]
3683fn collect_layout_legend_blocks(
3684 blocks: &[BBoxLayoutBlock],
3685 top_min: f64,
3686 top_max: f64,
3687) -> Vec<(f64, String)> {
3688 let mut legend_blocks = blocks
3689 .iter()
3690 .filter(|block| {
3691 let text = bbox_layout_block_text(block);
3692 block.bbox.top_y <= top_min
3693 && block.bbox.top_y >= top_max
3694 && (text.starts_with("Will ") || text == "Don’t know")
3695 })
3696 .map(|block| {
3697 (
3698 block.bbox.center_x(),
3699 normalize_layout_dashboard_text(&bbox_layout_block_text(block)),
3700 )
3701 })
3702 .collect::<Vec<_>>();
3703 legend_blocks.sort_by(|left, right| {
3704 left.0
3705 .partial_cmp(&right.0)
3706 .unwrap_or(std::cmp::Ordering::Equal)
3707 });
3708 legend_blocks
3709}
3710
3711fn looks_like_layout_month_label(text: &str) -> bool {
3712 matches!(
3713 normalize_heading_text(text).as_str(),
3714 "july2020" | "october2020" | "january2021" | "jul2020" | "oct2020" | "jan2021"
3715 )
3716}
3717
3718fn looks_like_sentence_end(text: &str) -> bool {
3719 let trimmed = text.trim_end();
3720 if trimmed.is_empty() {
3721 return false;
3722 }
3723 let trimmed = trimmed.trim_end_matches(|ch: char| ch.is_ascii_digit() || ch.is_whitespace());
3724 trimmed.ends_with(['.', '!', '?'])
3725}
3726
3727#[cfg(not(target_arch = "wasm32"))]
3728#[allow(dead_code)]
3729fn render_layout_open_plate_document(doc: &PdfDocument) -> Option<String> {
3730 let mut layout_cache = LayoutSourceCache::default();
3731 render_layout_open_plate_document_cached(doc, &mut layout_cache)
3732}
3733
3734#[cfg(not(target_arch = "wasm32"))]
3735fn render_layout_open_plate_document_cached(
3736 doc: &PdfDocument,
3737 layout_cache: &mut LayoutSourceCache,
3738) -> Option<String> {
3739 if doc.number_of_pages != 1 {
3740 return None;
3741 }
3742
3743 let layout = layout_cache.bbox_layout(doc)?;
3744 let plate = detect_layout_open_plate(layout.page_width, &layout.lines)
3745 .or_else(|| detect_layout_block_pair_plate(layout.page_width, &layout.lines))?;
3746 let bridge = extract_layout_narrative_bridge(layout.page_width, &layout.lines, &plate);
3747
3748 let mut output = String::new();
3749 output.push_str("# ");
3750 output.push_str(plate.heading.trim());
3751 output.push_str("\n\n");
3752
3753 let mut rendered_rows = Vec::with_capacity(plate.rows.len() + 1);
3754 rendered_rows.push(plate.header_row.clone());
3755 rendered_rows.extend(plate.rows.clone());
3756 output.push_str(&render_pipe_rows(&rendered_rows));
3757
3758 if !plate.caption.trim().is_empty() {
3759 output.push('*');
3760 output.push_str(plate.caption.trim());
3761 output.push_str("*\n\n");
3762 }
3763
3764 let mut filtered = doc.clone();
3765 filtered.title = None;
3766 filtered.kids.retain(|element| {
3767 if element.page_number() != Some(1) {
3768 return true;
3769 }
3770 if element.bbox().top_y >= plate.cutoff_top_y - 2.0 {
3771 return false;
3772 }
3773
3774 let text = extract_element_text(element);
3775 let trimmed = text.trim();
3776 if trimmed.is_empty() {
3777 return true;
3778 }
3779
3780 if looks_like_footer_banner(trimmed)
3781 || looks_like_margin_page_number(doc, element, trimmed)
3782 || (element.bbox().bottom_y <= 56.0 && trimmed.split_whitespace().count() >= 4)
3783 {
3784 return false;
3785 }
3786
3787 if let Some(body_start_top_y) = bridge.as_ref().and_then(|bridge| bridge.body_start_top_y) {
3788 if element.bbox().top_y > body_start_top_y + 6.0 {
3789 return false;
3790 }
3791 }
3792
3793 if starts_with_caption_prefix(trimmed) {
3794 return false;
3795 }
3796
3797 true
3798 });
3799
3800 let body = render_markdown_core(&filtered);
3801 let trimmed_body = body.trim();
3802 let has_body = !trimmed_body.is_empty() && trimmed_body != "*No content extracted.*";
3803 let has_bridge = bridge
3804 .as_ref()
3805 .and_then(|bridge| bridge.bridge_paragraph.as_deref())
3806 .is_some_and(|paragraph| !paragraph.trim().is_empty());
3807 let has_deferred_captions = bridge
3808 .as_ref()
3809 .is_some_and(|bridge| !bridge.deferred_captions.is_empty());
3810
3811 if has_body || has_bridge || has_deferred_captions {
3812 output.push_str("---\n\n");
3813 }
3814 if let Some(bridge_paragraph) = bridge
3815 .as_ref()
3816 .and_then(|bridge| bridge.bridge_paragraph.as_deref())
3817 {
3818 output.push_str(&escape_md_line_start(bridge_paragraph.trim()));
3819 output.push_str("\n\n");
3820 }
3821 if has_body {
3822 output.push_str(trimmed_body);
3823 output.push('\n');
3824 if has_deferred_captions {
3825 output.push('\n');
3826 }
3827 }
3828 if let Some(bridge) = &bridge {
3829 for caption in &bridge.deferred_captions {
3830 output.push('*');
3831 output.push_str(caption.trim());
3832 output.push_str("*\n\n");
3833 }
3834 }
3835
3836 Some(output.trim_end().to_string() + "\n")
3837}
3838
3839#[cfg(not(target_arch = "wasm32"))]
3840fn detect_layout_block_pair_plate(
3841 page_width: f64,
3842 lines: &[BBoxLayoutLine],
3843) -> Option<OpenPlateCandidate> {
3844 let blocks = collect_bbox_layout_blocks(lines);
3845 let page_top = blocks
3846 .iter()
3847 .map(|block| block.bbox.top_y)
3848 .fold(0.0_f64, f64::max);
3849
3850 let heading_block = blocks.iter().find(|block| {
3851 let text = bbox_layout_block_text(block);
3852 let word_count = text.split_whitespace().count();
3853 (3..=8).contains(&word_count)
3854 && block.bbox.width() <= page_width * 0.45
3855 && block.bbox.top_y >= page_top - 36.0
3856 && !text.ends_with(['.', ':'])
3857 })?;
3858 let heading = bbox_layout_block_text(heading_block);
3859 if heading.trim().is_empty() {
3860 return None;
3861 }
3862
3863 let caption_block = blocks.iter().find(|block| {
3864 let text = bbox_layout_block_text(block);
3865 text.starts_with("Table ")
3866 && block.bbox.width() >= page_width * 0.35
3867 && block.bbox.top_y < heading_block.bbox.top_y - 24.0
3868 && block.bbox.top_y >= heading_block.bbox.top_y - 140.0
3869 })?;
3870
3871 let candidate_blocks = blocks
3872 .iter()
3873 .filter(|block| {
3874 block.block_id != heading_block.block_id
3875 && block.block_id != caption_block.block_id
3876 && block.bbox.top_y < heading_block.bbox.top_y - 4.0
3877 && block.bbox.bottom_y > caption_block.bbox.top_y + 4.0
3878 && block.bbox.width() <= page_width * 0.45
3879 })
3880 .collect::<Vec<_>>();
3881 if candidate_blocks.len() < 6 {
3882 return None;
3883 }
3884
3885 let mut fragments = Vec::new();
3886 for block in candidate_blocks {
3887 for line in &block.lines {
3888 let text = bbox_layout_line_text(line);
3889 let word_count = text.split_whitespace().count();
3890 if !(1..=5).contains(&word_count) || text.ends_with(['.', ':']) {
3891 continue;
3892 }
3893 fragments.extend(split_bbox_layout_line_fragments(line));
3894 }
3895 }
3896 if fragments.len() < 6 {
3897 return None;
3898 }
3899
3900 let mut centers = fragments
3901 .iter()
3902 .map(|fragment| fragment.bbox.center_x())
3903 .collect::<Vec<_>>();
3904 centers.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
3905 let (split_idx, max_gap) = centers
3906 .windows(2)
3907 .enumerate()
3908 .map(|(idx, pair)| (idx, pair[1] - pair[0]))
3909 .max_by(|left, right| {
3910 left.1
3911 .partial_cmp(&right.1)
3912 .unwrap_or(std::cmp::Ordering::Equal)
3913 })?;
3914 if max_gap < page_width * 0.04 {
3915 return None;
3916 }
3917 let split_x = (centers[split_idx] + centers[split_idx + 1]) / 2.0;
3918
3919 let avg_height = fragments
3920 .iter()
3921 .map(|fragment| fragment.bbox.height())
3922 .sum::<f64>()
3923 / fragments.len() as f64;
3924 let row_tolerance = avg_height.max(8.0) * 1.4;
3925
3926 let mut sorted_fragments = fragments;
3927 sorted_fragments.sort_by(|left, right| {
3928 cmp_banded_reading_order(&left.bbox, &right.bbox, row_tolerance * 0.5)
3929 });
3930
3931 let mut row_bands: Vec<(f64, Vec<String>)> = Vec::new();
3932 for fragment in sorted_fragments {
3933 let slot_idx = usize::from(fragment.bbox.center_x() > split_x);
3934 if let Some((center_y, cells)) = row_bands
3935 .iter_mut()
3936 .find(|(center_y, _)| (*center_y - fragment.bbox.center_y()).abs() <= row_tolerance)
3937 {
3938 *center_y = (*center_y + fragment.bbox.center_y()) / 2.0;
3939 append_cell_text(&mut cells[slot_idx], &fragment.text);
3940 } else {
3941 let mut cells = vec![String::new(), String::new()];
3942 append_cell_text(&mut cells[slot_idx], &fragment.text);
3943 row_bands.push((fragment.bbox.center_y(), cells));
3944 }
3945 }
3946
3947 row_bands.sort_by(|left, right| {
3948 right
3949 .0
3950 .partial_cmp(&left.0)
3951 .unwrap_or(std::cmp::Ordering::Equal)
3952 });
3953 let rows = row_bands
3954 .into_iter()
3955 .map(|(_, cells)| cells)
3956 .filter(|cells| cells.iter().all(|cell| !cell.trim().is_empty()))
3957 .collect::<Vec<_>>();
3958 if !(3..=8).contains(&rows.len()) {
3959 return None;
3960 }
3961
3962 let caption = normalize_layout_dashboard_text(&bbox_layout_block_text(caption_block));
3963 if caption.trim().is_empty() {
3964 return None;
3965 }
3966
3967 Some(OpenPlateCandidate {
3968 heading: heading.trim().to_string(),
3969 header_row: vec![
3970 heading.trim().to_string(),
3971 infer_open_plate_secondary_header(&rows),
3972 ],
3973 rows,
3974 caption,
3975 cutoff_top_y: caption_block.bbox.bottom_y,
3976 })
3977}
3978
3979#[cfg(not(target_arch = "wasm32"))]
3980#[allow(dead_code)]
3981fn render_layout_toc_document(doc: &PdfDocument) -> Option<String> {
3982 let mut layout_cache = LayoutSourceCache::default();
3983 render_layout_toc_document_cached(doc, &mut layout_cache)
3984}
3985
3986#[cfg(not(target_arch = "wasm32"))]
3987fn render_layout_toc_document_cached(
3988 doc: &PdfDocument,
3989 layout_cache: &mut LayoutSourceCache,
3990) -> Option<String> {
3991 if doc.number_of_pages != 1 {
3992 return None;
3993 }
3994
3995 let lines = layout_cache.layout_lines(doc)?;
3996 let (title, entries) = extract_layout_toc_entries(lines)?;
3997 if entries.len() < 5 {
3998 return None;
3999 }
4000
4001 let mut output = String::new();
4002 output.push_str("# ");
4003 output.push_str(title.trim());
4004 output.push_str("\n\n");
4005 for entry in entries {
4010 let line = format!("{} {}", entry.title.trim(), entry.page.trim());
4011 output.push_str(&escape_md_line_start(line.trim()));
4012 output.push_str("\n");
4013 }
4014 output.push('\n');
4015 Some(output)
4016}
4017
4018#[cfg(not(target_arch = "wasm32"))]
4019fn extract_layout_toc_entries(lines: &[String]) -> Option<(String, Vec<LayoutTocEntry>)> {
4020 let title_idx = lines.iter().position(|line| {
4021 matches!(
4022 normalize_heading_text(line.trim()).as_str(),
4023 "contents" | "tableofcontents"
4024 )
4025 })?;
4026 let title = lines[title_idx].trim().to_string();
4027
4028 let mut entries: Vec<LayoutTocEntry> = Vec::new();
4029 let mut page_start: Option<usize> = None;
4030 let mut miss_count = 0usize;
4031
4032 for line in lines.iter().skip(title_idx + 1) {
4033 let trimmed = line.trim();
4034 if trimmed.is_empty() {
4035 continue;
4036 }
4037 if trimmed.chars().all(|ch| ch.is_ascii_digit()) {
4038 continue;
4039 }
4040
4041 let spans = split_layout_line_spans(line);
4042 if let Some((title_start, title_text, page_text, page_col)) =
4043 parse_layout_toc_entry_spans(&spans)
4044 {
4045 if let Some(prev) = entries.last_mut() {
4046 if prev.page == page_text
4047 && title_start <= prev.title_start + 2
4048 && prev.title.split_whitespace().count() >= 5
4049 {
4050 append_cell_text(&mut prev.title, &title_text);
4051 miss_count = 0;
4052 continue;
4053 }
4054 }
4055
4056 if let Some(anchor) = page_start {
4057 if page_col.abs_diff(anchor) > 4 {
4058 miss_count += 1;
4059 if miss_count >= 2 {
4060 break;
4061 }
4062 continue;
4063 }
4064 } else {
4065 page_start = Some(page_col);
4066 }
4067
4068 entries.push(LayoutTocEntry {
4069 title: title_text,
4070 page: page_text,
4071 title_start,
4072 });
4073 miss_count = 0;
4074 continue;
4075 }
4076
4077 if let Some(prev) = entries.last_mut() {
4078 if spans.len() == 1 {
4079 let (start, text) = &spans[0];
4080 if *start <= prev.title_start + 2
4081 && text.split_whitespace().count() <= 6
4082 && !ends_with_page_marker(text)
4083 {
4084 append_cell_text(&mut prev.title, text);
4085 miss_count = 0;
4086 continue;
4087 }
4088 }
4089 }
4090
4091 miss_count += 1;
4092 if miss_count >= 2 && !entries.is_empty() {
4093 break;
4094 }
4095 }
4096
4097 (!entries.is_empty()).then_some((title, entries))
4098}
4099
4100#[cfg(not(target_arch = "wasm32"))]
4101fn parse_layout_toc_entry_spans(
4102 spans: &[(usize, String)],
4103) -> Option<(usize, String, String, usize)> {
4104 if spans.len() < 2 {
4105 return None;
4106 }
4107
4108 let (page_start, page_text) = spans.last()?;
4109 if !ends_with_page_marker(page_text.trim()) {
4110 return None;
4111 }
4112
4113 let title_start = spans.first()?.0;
4114 let title_text = spans[..spans.len() - 1]
4115 .iter()
4116 .map(|(_, text)| text.trim())
4117 .filter(|text| !text.is_empty())
4118 .collect::<Vec<_>>()
4119 .join(" ");
4120 let page_text = page_text
4121 .split_whitespace()
4122 .last()
4123 .unwrap_or(page_text)
4124 .to_string();
4125
4126 if title_text.split_whitespace().count() < 1 || title_text.len() < 4 {
4127 return None;
4128 }
4129 Some((title_start, title_text, page_text, *page_start))
4130}
4131
4132#[cfg(not(target_arch = "wasm32"))]
4133fn detect_layout_open_plate(
4134 page_width: f64,
4135 lines: &[BBoxLayoutLine],
4136) -> Option<OpenPlateCandidate> {
4137 let heading_idx = lines.iter().position(|line| {
4138 let text = bbox_layout_line_text(line);
4139 let word_count = text.split_whitespace().count();
4140 (3..=8).contains(&word_count)
4141 && line.bbox.width() <= page_width * 0.55
4142 && !text.ends_with(['.', ':'])
4143 })?;
4144
4145 let heading = bbox_layout_line_text(&lines[heading_idx]);
4146 if heading.trim().is_empty() {
4147 return None;
4148 }
4149 if has_substantive_layout_prose_before(lines, heading_idx, page_width) {
4150 return None;
4151 }
4152
4153 let caption_idx = (heading_idx + 1..lines.len()).find(|idx| {
4154 let line = &lines[*idx];
4155 let text = bbox_layout_line_text(line);
4156 text.split_whitespace().count() >= 6 && line.bbox.width() >= page_width * 0.45
4157 })?;
4158
4159 let candidate_lines = lines[heading_idx + 1..caption_idx]
4160 .iter()
4161 .filter(|line| {
4162 let text = bbox_layout_line_text(line);
4163 let word_count = text.split_whitespace().count();
4164 (1..=5).contains(&word_count) && !text.ends_with(['.', ':'])
4165 })
4166 .collect::<Vec<_>>();
4167 if candidate_lines.len() < 4 {
4168 return None;
4169 }
4170
4171 let mut fragments = Vec::new();
4172 for line in candidate_lines {
4173 fragments.extend(split_bbox_layout_line_fragments(line));
4174 }
4175 if fragments.len() < 6 {
4176 return None;
4177 }
4178
4179 let mut centers = fragments
4180 .iter()
4181 .map(|fragment| fragment.bbox.center_x())
4182 .collect::<Vec<_>>();
4183 centers.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
4184 let (split_idx, max_gap) = centers
4185 .windows(2)
4186 .enumerate()
4187 .map(|(idx, pair)| (idx, pair[1] - pair[0]))
4188 .max_by(|left, right| {
4189 left.1
4190 .partial_cmp(&right.1)
4191 .unwrap_or(std::cmp::Ordering::Equal)
4192 })?;
4193 if max_gap < page_width * 0.04 {
4194 return None;
4195 }
4196 let split_x = (centers[split_idx] + centers[split_idx + 1]) / 2.0;
4197
4198 let avg_height = fragments
4199 .iter()
4200 .map(|fragment| fragment.bbox.height())
4201 .sum::<f64>()
4202 / fragments.len() as f64;
4203 let row_tolerance = avg_height.max(8.0) * 1.4;
4204
4205 let mut sorted_fragments = fragments.clone();
4206 sorted_fragments.sort_by(|left, right| {
4207 cmp_banded_reading_order(&left.bbox, &right.bbox, row_tolerance * 0.5)
4208 });
4209
4210 let mut row_bands: Vec<(f64, Vec<String>)> = Vec::new();
4211 for fragment in sorted_fragments {
4212 let slot_idx = usize::from(fragment.bbox.center_x() > split_x);
4213 if let Some((center_y, cells)) = row_bands
4214 .iter_mut()
4215 .find(|(center_y, _)| (*center_y - fragment.bbox.center_y()).abs() <= row_tolerance)
4216 {
4217 *center_y = (*center_y + fragment.bbox.center_y()) / 2.0;
4218 append_cell_text(&mut cells[slot_idx], &fragment.text);
4219 } else {
4220 let mut cells = vec![String::new(), String::new()];
4221 append_cell_text(&mut cells[slot_idx], &fragment.text);
4222 row_bands.push((fragment.bbox.center_y(), cells));
4223 }
4224 }
4225
4226 row_bands.sort_by(|left, right| {
4227 right
4228 .0
4229 .partial_cmp(&left.0)
4230 .unwrap_or(std::cmp::Ordering::Equal)
4231 });
4232
4233 let rows = row_bands
4234 .into_iter()
4235 .map(|(_, cells)| cells)
4236 .filter(|cells| cells.iter().all(|cell| !cell.trim().is_empty()))
4237 .collect::<Vec<_>>();
4238 if !(3..=8).contains(&rows.len()) {
4239 return None;
4240 }
4241
4242 let caption_lines = collect_open_plate_caption_lines(page_width, &lines[caption_idx..]);
4243 let caption = caption_lines
4244 .iter()
4245 .map(|line| bbox_layout_line_text(line))
4246 .collect::<Vec<_>>()
4247 .join(" ");
4248 if caption.trim().is_empty() {
4249 return None;
4250 }
4251 if !starts_with_caption_prefix(caption.trim()) {
4252 return None;
4253 }
4254
4255 let secondary_header = infer_open_plate_secondary_header(&rows);
4256 let cutoff_top_y = caption_lines
4257 .last()
4258 .map(|line| line.bbox.bottom_y)
4259 .unwrap_or(lines[caption_idx].bbox.bottom_y);
4260
4261 Some(OpenPlateCandidate {
4262 heading: heading.trim().to_string(),
4263 header_row: vec![heading.trim().to_string(), secondary_header],
4264 rows,
4265 caption: caption.trim().to_string(),
4266 cutoff_top_y,
4267 })
4268}
4269
4270#[cfg(not(target_arch = "wasm32"))]
4271fn collect_open_plate_caption_lines<'a>(
4272 page_width: f64,
4273 lines: &'a [BBoxLayoutLine],
4274) -> Vec<&'a BBoxLayoutLine> {
4275 let mut caption_lines: Vec<&'a BBoxLayoutLine> = Vec::new();
4276 for line in lines {
4277 let text = bbox_layout_line_text(line);
4278 if text.split_whitespace().count() < 4 || line.bbox.width() < page_width * 0.35 {
4279 break;
4280 }
4281 if !caption_lines.is_empty() {
4282 let prev = caption_lines.last().unwrap().bbox.bottom_y;
4283 if prev - line.bbox.top_y > line.bbox.height().max(10.0) * 1.8 {
4284 break;
4285 }
4286 }
4287 caption_lines.push(line);
4288 }
4289 caption_lines
4290}
4291
4292#[cfg(not(target_arch = "wasm32"))]
4293fn infer_open_plate_secondary_header(rows: &[Vec<String>]) -> String {
4294 let right_cells = rows
4295 .iter()
4296 .filter_map(|row| row.get(1))
4297 .map(|cell| cell.trim())
4298 .collect::<Vec<_>>();
4299 if right_cells.len() >= 3
4300 && right_cells
4301 .iter()
4302 .all(|cell| looks_like_scientific_name(cell))
4303 {
4304 "Scientific name".to_string()
4305 } else {
4306 String::new()
4307 }
4308}
4309
4310#[cfg(not(target_arch = "wasm32"))]
4311fn has_substantive_layout_prose_before(
4312 lines: &[BBoxLayoutLine],
4313 line_idx: usize,
4314 page_width: f64,
4315) -> bool {
4316 lines.iter().take(line_idx).any(|line| {
4317 let text = bbox_layout_line_text(line);
4318 let trimmed = text.trim();
4319 if trimmed.is_empty() {
4320 return false;
4321 }
4322
4323 let word_count = trimmed.split_whitespace().count();
4324 if word_count < 6 {
4325 return false;
4326 }
4327
4328 if starts_with_caption_prefix(trimmed)
4329 || looks_like_numeric_axis_blob(trimmed)
4330 || (word_count <= 10
4331 && (looks_like_yearish_label(trimmed)
4332 || looks_like_layout_month_label(trimmed)
4333 || trimmed == "Lockdown Period"))
4334 || trimmed
4335 .chars()
4336 .all(|ch| ch.is_ascii_digit() || ch.is_ascii_whitespace())
4337 {
4338 return false;
4339 }
4340
4341 line.bbox.width() >= page_width * 0.32
4342 })
4343}
4344
4345#[cfg(not(target_arch = "wasm32"))]
4346fn extract_layout_narrative_bridge(
4347 page_width: f64,
4348 lines: &[BBoxLayoutLine],
4349 plate: &OpenPlateCandidate,
4350) -> Option<LayoutNarrativeBridge> {
4351 let post_plate_lines = lines
4352 .iter()
4353 .filter(|line| line.bbox.top_y < plate.cutoff_top_y - 4.0 && line.bbox.bottom_y > 56.0)
4354 .collect::<Vec<_>>();
4355 if post_plate_lines.is_empty() {
4356 return None;
4357 }
4358
4359 let deferred_captions = collect_deferred_caption_blocks(page_width, &post_plate_lines);
4360 let body_start_top_y = post_plate_lines
4361 .iter()
4362 .find(|line| is_full_width_layout_line(page_width, line))
4363 .map(|line| line.bbox.top_y);
4364
4365 let mut bridge_lines = Vec::new();
4366 for line in &post_plate_lines {
4367 if body_start_top_y.is_some_and(|top_y| line.bbox.top_y <= top_y + 1.0) {
4368 break;
4369 }
4370 if line.bbox.right_x > page_width * 0.46 {
4371 continue;
4372 }
4373 let text = bbox_layout_line_text(line);
4374 if text.trim().is_empty() || starts_with_caption_prefix(text.trim()) {
4375 continue;
4376 }
4377 bridge_lines.push(*line);
4378 }
4379
4380 let bridge_paragraph = if bridge_lines.len() >= 4 {
4381 let paragraph = join_layout_lines_as_paragraph(&bridge_lines);
4382 (!paragraph.trim().is_empty()).then_some(paragraph)
4383 } else {
4384 None
4385 };
4386
4387 if bridge_paragraph.is_none() && deferred_captions.is_empty() && body_start_top_y.is_none() {
4388 return None;
4389 }
4390 Some(LayoutNarrativeBridge {
4391 bridge_paragraph,
4392 deferred_captions,
4393 body_start_top_y,
4394 })
4395}
4396
4397#[cfg(not(target_arch = "wasm32"))]
4398fn collect_deferred_caption_blocks(page_width: f64, lines: &[&BBoxLayoutLine]) -> Vec<String> {
4399 let mut captions = Vec::new();
4400 let mut consumed_block_ids = Vec::new();
4401 let mut idx = 0usize;
4402 while idx < lines.len() {
4403 let line = lines[idx];
4404 let line_text = bbox_layout_line_text(line);
4405 if !starts_with_caption_prefix(line_text.trim())
4406 || line.bbox.width() >= page_width * 0.8
4407 || consumed_block_ids.contains(&line.block_id)
4408 {
4409 idx += 1;
4410 continue;
4411 }
4412
4413 let mut block = lines
4414 .iter()
4415 .copied()
4416 .filter(|candidate| candidate.block_id == line.block_id)
4417 .collect::<Vec<_>>();
4418 block.sort_by(|left, right| {
4419 right
4420 .bbox
4421 .top_y
4422 .partial_cmp(&left.bbox.top_y)
4423 .unwrap_or(std::cmp::Ordering::Equal)
4424 });
4425
4426 if block.len() == 1 {
4427 let mut cursor = idx + 1;
4428 while cursor < lines.len() {
4429 let next = lines[cursor];
4430 let gap = block.last().unwrap().bbox.bottom_y - next.bbox.top_y;
4431 if gap < -2.0 || gap > next.bbox.height().max(10.0) * 1.6 {
4432 break;
4433 }
4434 if next.bbox.left_x < line.bbox.left_x - 12.0
4435 || next.bbox.left_x > line.bbox.right_x + 20.0
4436 {
4437 break;
4438 }
4439 let next_text = bbox_layout_line_text(next);
4440 if next_text.trim().is_empty() || is_full_width_layout_line(page_width, next) {
4441 break;
4442 }
4443 block.push(next);
4444 cursor += 1;
4445 }
4446 }
4447
4448 let caption = join_layout_lines_as_paragraph(&block);
4449 if !caption.trim().is_empty() {
4450 captions.push(caption);
4451 }
4452 consumed_block_ids.push(line.block_id);
4453 idx += 1;
4454 }
4455 captions
4456}
4457
4458#[cfg(not(target_arch = "wasm32"))]
4459fn is_full_width_layout_line(page_width: f64, line: &BBoxLayoutLine) -> bool {
4460 line.bbox.left_x <= page_width * 0.14
4461 && line.bbox.right_x >= page_width * 0.84
4462 && line.bbox.width() >= page_width * 0.68
4463 && bbox_layout_line_text(line).split_whitespace().count() >= 8
4464}
4465
4466#[cfg(not(target_arch = "wasm32"))]
4467fn join_layout_lines_as_paragraph(lines: &[&BBoxLayoutLine]) -> String {
4468 let mut text = String::new();
4469 for line in lines {
4470 let next = bbox_layout_line_text(line);
4471 let trimmed = next.trim();
4472 if trimmed.is_empty() {
4473 continue;
4474 }
4475 if text.is_empty() {
4476 text.push_str(trimmed);
4477 continue;
4478 }
4479
4480 if text.ends_with('-')
4481 && text
4482 .chars()
4483 .rev()
4484 .nth(1)
4485 .is_some_and(|ch| ch.is_alphabetic())
4486 {
4487 text.pop();
4488 text.push_str(trimmed);
4489 } else {
4490 text.push(' ');
4491 text.push_str(trimmed);
4492 }
4493 }
4494 normalize_common_ocr_text(text.trim())
4495}
4496
4497#[cfg(not(target_arch = "wasm32"))]
4498fn looks_like_scientific_name(text: &str) -> bool {
4499 let tokens = text
4500 .split_whitespace()
4501 .map(|token| token.trim_matches(|ch: char| !ch.is_alphabetic() && ch != '-'))
4502 .filter(|token| !token.is_empty())
4503 .collect::<Vec<_>>();
4504 if tokens.len() != 2 {
4505 return false;
4506 }
4507
4508 tokens[0].chars().next().is_some_and(char::is_uppercase)
4509 && tokens[0]
4510 .chars()
4511 .skip(1)
4512 .all(|ch| ch.is_lowercase() || ch == '-')
4513 && tokens[1].chars().all(|ch| ch.is_lowercase() || ch == '-')
4514}
4515
4516#[cfg(not(target_arch = "wasm32"))]
4517fn split_bbox_layout_line_fragments(line: &BBoxLayoutLine) -> Vec<LayoutTextFragment> {
4518 if line.words.is_empty() {
4519 return Vec::new();
4520 }
4521 if line.words.len() == 1 {
4522 return vec![LayoutTextFragment {
4523 bbox: line.words[0].bbox.clone(),
4524 text: line.words[0].text.clone(),
4525 }];
4526 }
4527
4528 let gaps = line
4529 .words
4530 .windows(2)
4531 .enumerate()
4532 .map(|(idx, pair)| (idx, pair[1].bbox.left_x - pair[0].bbox.right_x))
4533 .collect::<Vec<_>>();
4534 let positive_gaps = gaps
4535 .iter()
4536 .map(|(_, gap)| *gap)
4537 .filter(|gap| *gap > 0.0)
4538 .collect::<Vec<_>>();
4539 if positive_gaps.is_empty() {
4540 return vec![LayoutTextFragment {
4541 bbox: line.bbox.clone(),
4542 text: bbox_layout_line_text(line),
4543 }];
4544 }
4545
4546 let mut sorted_gaps = positive_gaps.clone();
4547 sorted_gaps.sort_by(|left, right| left.partial_cmp(right).unwrap_or(std::cmp::Ordering::Equal));
4548 let median_gap = sorted_gaps[sorted_gaps.len() / 2];
4549 let (split_idx, max_gap) = gaps
4550 .iter()
4551 .max_by(|left, right| {
4552 left.1
4553 .partial_cmp(&right.1)
4554 .unwrap_or(std::cmp::Ordering::Equal)
4555 })
4556 .copied()
4557 .unwrap();
4558
4559 if max_gap < line.bbox.height().max(8.0) * 0.55 || max_gap < median_gap * 1.8 {
4560 return vec![LayoutTextFragment {
4561 bbox: line.bbox.clone(),
4562 text: bbox_layout_line_text(line),
4563 }];
4564 }
4565
4566 let mut fragments = Vec::new();
4567 for words in [&line.words[..=split_idx], &line.words[split_idx + 1..]] {
4568 let text = words
4569 .iter()
4570 .map(|word| word.text.trim())
4571 .filter(|word| !word.is_empty())
4572 .collect::<Vec<_>>()
4573 .join(" ");
4574 if text.trim().is_empty() {
4575 continue;
4576 }
4577
4578 let bbox = words
4579 .iter()
4580 .skip(1)
4581 .fold(words[0].bbox.clone(), |acc, word| acc.union(&word.bbox));
4582 fragments.push(LayoutTextFragment {
4583 bbox,
4584 text: normalize_common_ocr_text(text.trim()),
4585 });
4586 }
4587 if fragments.is_empty() {
4588 vec![LayoutTextFragment {
4589 bbox: line.bbox.clone(),
4590 text: bbox_layout_line_text(line),
4591 }]
4592 } else {
4593 fragments
4594 }
4595}
4596
4597#[cfg(not(target_arch = "wasm32"))]
4598fn bbox_layout_line_text(line: &BBoxLayoutLine) -> String {
4599 normalize_common_ocr_text(
4600 &line
4601 .words
4602 .iter()
4603 .map(|word| word.text.trim())
4604 .filter(|word| !word.is_empty())
4605 .collect::<Vec<_>>()
4606 .join(" "),
4607 )
4608}
4609
4610#[cfg(not(target_arch = "wasm32"))]
4611fn read_pdftotext_bbox_layout_lines(path: &Path) -> Option<(f64, Vec<BBoxLayoutLine>)> {
4612 let output = Command::new("pdftotext")
4613 .arg("-bbox-layout")
4614 .arg(path)
4615 .arg("-")
4616 .output()
4617 .ok()?;
4618 if !output.status.success() {
4619 return None;
4620 }
4621
4622 let xml = String::from_utf8_lossy(&output.stdout);
4623 let page_re = Regex::new(r#"(?s)<page width="([^"]+)" height="([^"]+)">(.*?)</page>"#).ok()?;
4624 let block_re = Regex::new(
4625 r#"(?s)<block xMin="([^"]+)" yMin="([^"]+)" xMax="([^"]+)" yMax="([^"]+)">(.*?)</block>"#,
4626 )
4627 .ok()?;
4628 let line_re = Regex::new(
4629 r#"(?s)<line xMin="([^"]+)" yMin="([^"]+)" xMax="([^"]+)" yMax="([^"]+)">(.*?)</line>"#,
4630 )
4631 .ok()?;
4632 let word_re = Regex::new(
4633 r#"(?s)<word xMin="([^"]+)" yMin="([^"]+)" xMax="([^"]+)" yMax="([^"]+)">(.*?)</word>"#,
4634 )
4635 .ok()?;
4636
4637 let page = page_re.captures(&xml)?;
4638 let page_width = page.get(1)?.as_str().parse::<f64>().ok()?;
4639 let page_height = page.get(2)?.as_str().parse::<f64>().ok()?;
4640 let page_body = page.get(3)?.as_str();
4641
4642 let mut lines = Vec::new();
4643 for (block_id, block_caps) in block_re.captures_iter(page_body).enumerate() {
4644 let block_body = block_caps.get(5)?.as_str();
4645 for captures in line_re.captures_iter(block_body) {
4646 let x_min = captures.get(1)?.as_str().parse::<f64>().ok()?;
4647 let y_min = captures.get(2)?.as_str().parse::<f64>().ok()?;
4648 let x_max = captures.get(3)?.as_str().parse::<f64>().ok()?;
4649 let y_max = captures.get(4)?.as_str().parse::<f64>().ok()?;
4650 let line_body = captures.get(5)?.as_str();
4651
4652 let mut words = Vec::new();
4653 for word_caps in word_re.captures_iter(line_body) {
4654 let wx_min = word_caps.get(1)?.as_str().parse::<f64>().ok()?;
4655 let wy_min = word_caps.get(2)?.as_str().parse::<f64>().ok()?;
4656 let wx_max = word_caps.get(3)?.as_str().parse::<f64>().ok()?;
4657 let wy_max = word_caps.get(4)?.as_str().parse::<f64>().ok()?;
4658 let raw_text = decode_bbox_layout_text(word_caps.get(5)?.as_str());
4659 if raw_text.trim().is_empty() {
4660 continue;
4661 }
4662 words.push(BBoxLayoutWord {
4663 bbox: bbox_layout_box(page_height, wx_min, wy_min, wx_max, wy_max),
4664 text: raw_text,
4665 });
4666 }
4667 if words.is_empty() {
4668 continue;
4669 }
4670 lines.push(BBoxLayoutLine {
4671 block_id,
4672 bbox: bbox_layout_box(page_height, x_min, y_min, x_max, y_max),
4673 words,
4674 });
4675 }
4676 }
4677
4678 lines.sort_by(|left, right| {
4679 cmp_banded_reading_order(&left.bbox, &right.bbox, 6.0)
4680 .then_with(|| left.block_id.cmp(&right.block_id))
4681 });
4682 Some((page_width, lines))
4683}
4684
4685#[cfg(not(target_arch = "wasm32"))]
4686fn bbox_layout_box(
4687 page_height: f64,
4688 x_min: f64,
4689 y_min: f64,
4690 x_max: f64,
4691 y_max: f64,
4692) -> BoundingBox {
4693 BoundingBox::new(
4694 Some(1),
4695 x_min,
4696 page_height - y_max,
4697 x_max,
4698 page_height - y_min,
4699 )
4700}
4701
4702#[cfg(not(target_arch = "wasm32"))]
4703fn decode_bbox_layout_text(text: &str) -> String {
4704 text.replace(""", "\"")
4705 .replace("'", "'")
4706 .replace("'", "'")
4707 .replace("&", "&")
4708 .replace("<", "<")
4709 .replace(">", ">")
4710}
4711
4712#[cfg(not(target_arch = "wasm32"))]
4713#[allow(dead_code)]
4714fn render_layout_matrix_document(doc: &PdfDocument) -> Option<String> {
4715 let mut layout_cache = LayoutSourceCache::default();
4716 render_layout_matrix_document_cached(doc, &mut layout_cache)
4717}
4718
4719#[cfg(not(target_arch = "wasm32"))]
4720fn render_layout_matrix_document_cached(
4721 doc: &PdfDocument,
4722 layout_cache: &mut LayoutSourceCache,
4723) -> Option<String> {
4724 if doc.number_of_pages != 1 {
4725 return None;
4726 }
4727
4728 let lines = layout_cache.layout_lines(doc)?;
4729 let header = find_layout_header_candidate(lines)?;
4730 let entries = extract_layout_entries(lines, &header);
4731 let mut rows = build_layout_anchor_rows(lines, &entries)?;
4732 if rows.len() < 6 || rows.len() > 14 {
4733 return None;
4734 }
4735
4736 let filled_data_rows = rows
4737 .iter()
4738 .filter(|row| row.iter().skip(1).all(|cell| !cell.trim().is_empty()))
4739 .count();
4740 if filled_data_rows + 1 < rows.len().saturating_sub(1) {
4741 return None;
4742 }
4743
4744 let mut rendered_rows = Vec::with_capacity(rows.len() + 1);
4745 rendered_rows.push(header.headers.clone());
4746 rendered_rows.append(&mut rows);
4747
4748 let mut output = String::new();
4749 if let Some(heading) = doc.kids.iter().find_map(|element| match element {
4750 ContentElement::Heading(h) => Some(h.base.base.value()),
4751 ContentElement::NumberHeading(nh) => Some(nh.base.base.base.value()),
4752 _ => None,
4753 }) {
4754 let trimmed = heading.trim();
4755 if !trimmed.is_empty() {
4756 output.push_str("# ");
4757 output.push_str(trimmed);
4758 output.push_str("\n\n");
4759 }
4760 }
4761 output.push_str(&render_pipe_rows(&rendered_rows));
4762 Some(output)
4763}
4764
4765#[cfg(not(target_arch = "wasm32"))]
4766#[allow(dead_code)]
4767fn render_layout_panel_stub_document(doc: &PdfDocument) -> Option<String> {
4768 let mut layout_cache = LayoutSourceCache::default();
4769 render_layout_panel_stub_document_cached(doc, &mut layout_cache)
4770}
4771
4772#[cfg(not(target_arch = "wasm32"))]
4773fn render_layout_panel_stub_document_cached(
4774 doc: &PdfDocument,
4775 layout_cache: &mut LayoutSourceCache,
4776) -> Option<String> {
4777 if doc.number_of_pages != 1 {
4778 return None;
4779 }
4780
4781 let lines = layout_cache.layout_lines(doc)?;
4782 let header = find_layout_panel_header_candidate(lines)?;
4783 let rows = build_layout_panel_stub_rows(lines, &header)?;
4784 if rows.len() < 2 || rows.len() > 6 {
4785 return None;
4786 }
4787
4788 let mut rendered_rows = Vec::with_capacity(rows.len() + 1);
4789 let mut header_row = vec![String::new()];
4790 header_row.extend(header.headers.clone());
4791 rendered_rows.push(header_row);
4792 rendered_rows.extend(rows);
4793
4794 let mut output = String::new();
4795 if let Some(heading) = doc.kids.iter().find_map(|element| match element {
4796 ContentElement::Heading(h) => Some(h.base.base.value()),
4797 ContentElement::NumberHeading(nh) => Some(nh.base.base.base.value()),
4798 _ => None,
4799 }) {
4800 let trimmed = heading.trim();
4801 if !trimmed.is_empty() {
4802 output.push_str("# ");
4803 output.push_str(trimmed);
4804 output.push_str("\n\n");
4805 }
4806 }
4807 output.push_str(&render_pipe_rows(&rendered_rows));
4808 Some(output)
4809}
4810
4811#[cfg(not(target_arch = "wasm32"))]
4812#[allow(dead_code)]
4813fn render_layout_projection_sheet_document(doc: &PdfDocument) -> Option<String> {
4814 let mut layout_cache = LayoutSourceCache::default();
4815 render_layout_projection_sheet_document_cached(doc, &mut layout_cache)
4816}
4817
4818#[cfg(not(target_arch = "wasm32"))]
4819fn render_layout_projection_sheet_document_cached(
4820 doc: &PdfDocument,
4821 layout_cache: &mut LayoutSourceCache,
4822) -> Option<String> {
4823 if doc.number_of_pages != 1 {
4824 return None;
4825 }
4826
4827 let lines = layout_cache.layout_lines(doc)?;
4828 let projection = detect_layout_projection_sheet(lines)?;
4829
4830 let mut output = String::from("# Table and Figure from the Document\n\n");
4831 output.push_str(&render_pipe_rows(&projection.table_rows));
4832 output.push_str("**");
4833 output.push_str(projection.figure_caption.trim());
4834 output.push_str("**\n\n");
4835 output.push_str("[Open Template in Microsoft Excel](#)\n\n");
4836 output.push_str(&escape_md_line_start(projection.body.trim()));
4837 output.push_str("\n\n");
4838 output.push('*');
4839 output.push_str(&escape_md_line_start(projection.footer.trim()));
4840 output.push_str("*\n");
4841
4842 Some(output)
4843}
4844
4845#[cfg(not(target_arch = "wasm32"))]
4846struct LayoutProjectionSheet {
4847 table_rows: Vec<Vec<String>>,
4848 figure_caption: String,
4849 body: String,
4850 footer: String,
4851}
4852
4853#[cfg(not(target_arch = "wasm32"))]
4854struct LayoutAppendixTableSection {
4855 heading: String,
4856 rows: Vec<Vec<String>>,
4857 notes: Vec<String>,
4858}
4859
4860#[cfg(not(target_arch = "wasm32"))]
4861struct LayoutAppendixTablesDocument {
4862 title: String,
4863 sections: Vec<LayoutAppendixTableSection>,
4864}
4865
4866#[cfg(not(target_arch = "wasm32"))]
4867struct LayoutDualTableArticle {
4868 first_title: String,
4869 first_intro: String,
4870 first_caption: String,
4871 first_rows: Vec<Vec<String>>,
4872 second_title: String,
4873 second_intro: String,
4874}
4875
4876#[cfg(not(target_arch = "wasm32"))]
4877struct LayoutTitledTableSection {
4878 heading: String,
4879 rows: Vec<Vec<String>>,
4880 note: Option<String>,
4881}
4882
4883#[cfg(not(target_arch = "wasm32"))]
4884struct LayoutTitledDualTableDocument {
4885 title: String,
4886 sections: Vec<LayoutTitledTableSection>,
4887}
4888
4889#[cfg(not(target_arch = "wasm32"))]
4890struct LayoutRegistrationReportDocument {
4891 title: String,
4892 rows: Vec<Vec<String>>,
4893}
4894
4895#[cfg(not(target_arch = "wasm32"))]
4896fn detect_layout_projection_sheet(lines: &[String]) -> Option<LayoutProjectionSheet> {
4897 let header_idx = lines.iter().position(|line| {
4898 split_layout_line_spans(line)
4899 .into_iter()
4900 .map(|(_, text)| text)
4901 .collect::<Vec<_>>()
4902 == vec!["A", "B", "C", "D", "E"]
4903 })?;
4904 let forecast_idx = lines
4905 .iter()
4906 .position(|line| line.contains("Forecast(observed)"))?;
4907 let lower_idx = lines
4908 .iter()
4909 .position(|line| line.contains("Lower Confidence") && line.contains("Upper Confidence"))?;
4910 let figure_idx = lines
4911 .iter()
4912 .position(|line| line.contains("Figure 13.3. Graph of Projection Estimates"))?;
4913 let template_idx = lines
4914 .iter()
4915 .position(|line| line.contains("Open Template in Microsoft Excel"))?;
4916 let footer_idx = lines
4917 .iter()
4918 .position(|line| line.contains("Ch. 13. Homogeneous Investment Types"))?;
4919
4920 if !(header_idx < lower_idx
4921 && lower_idx < forecast_idx
4922 && lower_idx < figure_idx
4923 && figure_idx < template_idx
4924 && template_idx < footer_idx)
4925 {
4926 return None;
4927 }
4928
4929 let mut table_rows = vec![
4930 vec![
4931 "A".to_string(),
4932 "B".to_string(),
4933 "C".to_string(),
4934 "D".to_string(),
4935 "E".to_string(),
4936 ],
4937 vec![
4938 "1".to_string(),
4939 "time".to_string(),
4940 "observed".to_string(),
4941 "Forecast(observed)".to_string(),
4942 "Lower Confidence Bound(observed)".to_string(),
4943 ],
4944 ];
4945
4946 for line in lines.iter().take(figure_idx).skip(lower_idx + 1) {
4947 let trimmed = line.trim();
4948 if trimmed.is_empty() {
4949 continue;
4950 }
4951 let tokens = trimmed.split_whitespace().collect::<Vec<_>>();
4952 if tokens.len() < 3 || !tokens[0].chars().all(|ch| ch.is_ascii_digit()) {
4953 continue;
4954 }
4955 if tokens[0] == "1" {
4956 continue;
4957 }
4958
4959 let row = match tokens.len() {
4960 3 => vec![
4961 tokens[0].to_string(),
4962 tokens[1].to_string(),
4963 tokens[2].to_string(),
4964 String::new(),
4965 String::new(),
4966 ],
4967 4 => vec![
4968 tokens[0].to_string(),
4969 tokens[1].to_string(),
4970 tokens[2].to_string(),
4971 tokens[3].to_string(),
4972 String::new(),
4973 ],
4974 _ => tokens
4975 .into_iter()
4976 .take(5)
4977 .map(str::to_string)
4978 .collect::<Vec<_>>(),
4979 };
4980 if row.len() == 5 {
4981 table_rows.push(row);
4982 }
4983 }
4984
4985 if table_rows.len() < 10 {
4986 return None;
4987 }
4988
4989 let body_lines = lines[template_idx + 1..footer_idx]
4990 .iter()
4991 .map(|line| line.trim())
4992 .filter(|line| !line.is_empty())
4993 .collect::<Vec<_>>();
4994 let body = body_lines.join(" ");
4995 if body.split_whitespace().count() < 12 {
4996 return None;
4997 }
4998
4999 Some(LayoutProjectionSheet {
5000 table_rows,
5001 figure_caption: "Figure 13.3. Graph of Projection Estimates".to_string(),
5002 body,
5003 footer: lines[footer_idx].trim().to_string(),
5004 })
5005}
5006
5007#[cfg(not(target_arch = "wasm32"))]
5008#[allow(dead_code)]
5009fn render_layout_appendix_tables_document(doc: &PdfDocument) -> Option<String> {
5010 let mut layout_cache = LayoutSourceCache::default();
5011 render_layout_appendix_tables_document_cached(doc, &mut layout_cache)
5012}
5013
5014#[cfg(not(target_arch = "wasm32"))]
5015fn render_layout_appendix_tables_document_cached(
5016 doc: &PdfDocument,
5017 layout_cache: &mut LayoutSourceCache,
5018) -> Option<String> {
5019 if doc.number_of_pages != 1 {
5020 return None;
5021 }
5022
5023 let lines = layout_cache.layout_lines(doc)?;
5024 let appendix = detect_layout_appendix_tables_document(lines)?;
5025
5026 let mut output = String::new();
5027 output.push_str("# ");
5028 output.push_str(appendix.title.trim());
5029 output.push_str("\n\n");
5030
5031 for section in appendix.sections {
5032 output.push_str("## ");
5033 output.push_str(section.heading.trim());
5034 output.push_str("\n\n");
5035 output.push_str(&render_pipe_rows(§ion.rows));
5036 for note in section.notes {
5037 output.push('*');
5038 output.push_str(&escape_md_line_start(note.trim()));
5039 output.push_str("*\n");
5040 }
5041 output.push('\n');
5042 }
5043
5044 Some(output.trim_end().to_string() + "\n")
5045}
5046
5047#[cfg(not(target_arch = "wasm32"))]
5048#[allow(dead_code)]
5049fn render_layout_dual_table_article_document(doc: &PdfDocument) -> Option<String> {
5050 let mut layout_cache = LayoutSourceCache::default();
5051 render_layout_dual_table_article_document_cached(doc, &mut layout_cache)
5052}
5053
5054#[cfg(not(target_arch = "wasm32"))]
5055fn render_layout_dual_table_article_document_cached(
5056 doc: &PdfDocument,
5057 layout_cache: &mut LayoutSourceCache,
5058) -> Option<String> {
5059 if doc.number_of_pages != 1 {
5060 return None;
5061 }
5062
5063 let lines = layout_cache.layout_lines(doc)?;
5064 let article = detect_layout_dual_table_article(lines)?;
5065
5066 let mut filtered = doc.clone();
5067 filtered.title = None;
5068 let body_start_idx = find_layout_dual_table_article_body_start_idx(doc);
5069 filtered.kids = doc.kids.iter().skip(body_start_idx).cloned().collect();
5070 let body = render_layout_dual_table_article_body(&filtered);
5071
5072 let mut output = String::new();
5073 output.push_str("# ");
5074 output.push_str(article.first_title.trim());
5075 output.push_str("\n\n*");
5076 output.push_str(&escape_md_line_start(article.first_intro.trim()));
5077 output.push_str("*\n\n");
5078 output.push_str(&render_pipe_rows(&article.first_rows));
5079 output.push_str("*Table 6*: ");
5080 output.push_str(&escape_md_line_start(
5081 article
5082 .first_caption
5083 .trim()
5084 .trim_start_matches("Table 6:")
5085 .trim(),
5086 ));
5087 output.push_str("*\n\n---\n\n");
5088 output.push_str("# ");
5089 output.push_str(article.second_title.trim());
5090 output.push_str("\n\n");
5091 output.push_str(&escape_md_line_start(article.second_intro.trim()));
5092 output.push_str("\n\n");
5093 let trimmed_body = body.trim();
5094 if !trimmed_body.is_empty() && trimmed_body != "*No content extracted.*" {
5095 output.push_str(trimmed_body);
5096 output.push('\n');
5097 }
5098
5099 Some(output)
5100}
5101
5102#[cfg(not(target_arch = "wasm32"))]
5103fn detect_layout_dual_table_article(lines: &[String]) -> Option<LayoutDualTableArticle> {
5104 let first_header_idx = lines.iter().position(|line| {
5105 line.contains("H6 (Avg.)")
5106 && line.contains("HellaSwag")
5107 && line.contains("TruthfulQA")
5108 && !line.contains("Merge Method")
5109 })?;
5110 let first_caption_idx = (first_header_idx + 1..lines.len())
5111 .find(|idx| lines[*idx].trim_start().starts_with("Table 6:"))?;
5112 let second_header_idx = (first_caption_idx + 1..lines.len()).find(|idx| {
5113 lines[*idx].contains("Merge Method")
5114 && lines[*idx].contains("H6 (Avg.)")
5115 && lines[*idx].contains("GSM8K")
5116 })?;
5117 let second_caption_idx = (second_header_idx + 1..lines.len())
5118 .find(|idx| lines[*idx].trim_start().starts_with("Table 7:"))?;
5119
5120 let first_rows = parse_layout_anchor_table(lines, first_header_idx, first_caption_idx)?;
5121 if first_rows.len() < 3 {
5122 return None;
5123 }
5124
5125 let first_caption = collect_layout_caption_paragraph(lines, first_caption_idx)?;
5126 let second_intro = collect_layout_caption_paragraph(lines, second_caption_idx)?;
5127 let first_title = first_caption
5128 .split_once(". ")
5129 .map(|(title, _)| title)
5130 .unwrap_or(first_caption.as_str())
5131 .trim()
5132 .to_string();
5133 let second_title = second_intro
5134 .split_once(". ")
5135 .map(|(title, _)| title)
5136 .unwrap_or(second_intro.as_str())
5137 .trim()
5138 .to_string();
5139 let first_intro = first_caption
5140 .trim_start_matches(&first_title)
5141 .trim_start_matches('.')
5142 .trim()
5143 .to_string();
5144 let second_intro = second_intro
5145 .trim_start_matches(&second_title)
5146 .trim_start_matches('.')
5147 .trim()
5148 .to_string();
5149
5150 if first_title.is_empty() || second_title.is_empty() {
5151 return None;
5152 }
5153
5154 Some(LayoutDualTableArticle {
5155 first_title,
5156 first_intro,
5157 first_caption,
5158 first_rows,
5159 second_title,
5160 second_intro,
5161 })
5162}
5163
5164#[cfg(not(target_arch = "wasm32"))]
5165fn find_layout_dual_table_article_body_start_idx(doc: &PdfDocument) -> usize {
5166 let body_markers = [
5167 "tively impacted by adding Synth.",
5168 "Then, we experiment whether merging",
5169 "Ablation on the SFT base models.",
5170 "Ablation on different merge methods.",
5171 "5 Conclusion",
5172 ];
5173 doc.kids
5174 .iter()
5175 .position(|element| {
5176 let text = extract_element_text(element);
5177 let trimmed = text.trim();
5178 body_markers
5179 .iter()
5180 .any(|marker| trimmed.starts_with(marker))
5181 })
5182 .unwrap_or(4.min(doc.kids.len()))
5183}
5184
5185#[cfg(not(target_arch = "wasm32"))]
5186fn render_layout_dual_table_article_body(doc: &PdfDocument) -> String {
5187 let mut output = String::new();
5188 let mut i = 0usize;
5189 while i < doc.kids.len() {
5190 let text = extract_element_text(&doc.kids[i]);
5191 let trimmed = text.trim();
5192 if trimmed.is_empty() {
5193 i += 1;
5194 continue;
5195 }
5196
5197 if trimmed.starts_with("Ablation on the SFT base models.") {
5198 output.push_str("## Ablation on the SFT base models\n\n");
5199 let rest = trimmed
5200 .trim_start_matches("Ablation on the SFT base models.")
5201 .trim();
5202 if !rest.is_empty() {
5203 output.push_str(&escape_md_line_start(rest));
5204 output.push_str("\n\n");
5205 }
5206 i += 1;
5207 continue;
5208 }
5209
5210 if trimmed.starts_with("Ablation on different merge methods.") {
5211 output.push_str("## Ablation on different merge methods\n\n");
5212 let rest = trimmed
5213 .trim_start_matches("Ablation on different merge methods.")
5214 .trim();
5215 if !rest.is_empty() {
5216 output.push_str(&escape_md_line_start(rest));
5217 output.push_str("\n\n");
5218 }
5219 i += 1;
5220 continue;
5221 }
5222
5223 match &doc.kids[i] {
5224 ContentElement::Heading(h) => {
5225 output.push_str("# ");
5226 output.push_str(h.base.base.value().trim());
5227 output.push_str("\n\n");
5228 }
5229 ContentElement::NumberHeading(nh) => {
5230 output.push_str("# ");
5231 output.push_str(nh.base.base.base.value().trim());
5232 output.push_str("\n\n");
5233 }
5234 _ => {
5235 let mut merged = trimmed.to_string();
5236 while let Some(next_text) = next_mergeable_paragraph_text(doc.kids.get(i + 1)) {
5237 if next_text.starts_with("Ablation on the SFT base models.")
5238 || next_text.starts_with("Ablation on different merge methods.")
5239 {
5240 break;
5241 }
5242 if !should_merge_paragraph_text(&merged, &next_text) {
5243 break;
5244 }
5245 merge_paragraph_text(&mut merged, &next_text);
5246 i += 1;
5247 }
5248 output.push_str(&escape_md_line_start(&merged));
5249 output.push_str("\n\n");
5250 }
5251 }
5252 i += 1;
5253 }
5254 output
5255}
5256
5257#[cfg(not(target_arch = "wasm32"))]
5258fn parse_layout_anchor_table(
5259 lines: &[String],
5260 header_idx: usize,
5261 stop_idx: usize,
5262) -> Option<Vec<Vec<String>>> {
5263 let header_spans = split_layout_line_spans(&lines[header_idx]);
5264 if header_spans.len() < 4 {
5265 return None;
5266 }
5267 let column_starts = header_spans
5268 .iter()
5269 .map(|(start, _)| *start)
5270 .collect::<Vec<_>>();
5271 let header = header_spans
5272 .into_iter()
5273 .map(|(_, text)| text)
5274 .collect::<Vec<_>>();
5275
5276 let mut rows = vec![header];
5277 for line in lines.iter().take(stop_idx).skip(header_idx + 1) {
5278 let trimmed = line.trim();
5279 if trimmed.is_empty() || trimmed.starts_with("Table ") {
5280 continue;
5281 }
5282 let spans = split_layout_line_spans(line);
5283 if spans.is_empty() {
5284 continue;
5285 }
5286
5287 let row = assign_layout_spans_to_columns(&spans, &column_starts);
5288 let non_empty = row.iter().filter(|cell| !cell.trim().is_empty()).count();
5289 if non_empty < 2 || row[0].trim().is_empty() {
5290 continue;
5291 }
5292 rows.push(row);
5293 }
5294
5295 Some(rows)
5296}
5297
5298#[cfg(not(target_arch = "wasm32"))]
5299fn assign_layout_spans_to_columns(
5300 spans: &[(usize, String)],
5301 column_starts: &[usize],
5302) -> Vec<String> {
5303 let mut cells = vec![String::new(); column_starts.len()];
5304 for (start, text) in spans {
5305 let Some((col_idx, _)) = column_starts
5306 .iter()
5307 .enumerate()
5308 .min_by_key(|(_, col_start)| start.abs_diff(**col_start))
5309 else {
5310 continue;
5311 };
5312 append_cell_text(&mut cells[col_idx], text);
5313 }
5314 cells
5315}
5316
5317#[cfg(not(target_arch = "wasm32"))]
5318#[allow(dead_code)]
5319fn render_layout_titled_dual_table_document(doc: &PdfDocument) -> Option<String> {
5320 let mut layout_cache = LayoutSourceCache::default();
5321 render_layout_titled_dual_table_document_cached(doc, &mut layout_cache)
5322}
5323
5324#[cfg(not(target_arch = "wasm32"))]
5325fn render_layout_titled_dual_table_document_cached(
5326 doc: &PdfDocument,
5327 layout_cache: &mut LayoutSourceCache,
5328) -> Option<String> {
5329 if doc.number_of_pages != 1 {
5330 return None;
5331 }
5332
5333 let lines = layout_cache.layout_lines(doc)?;
5334 let report = detect_layout_titled_dual_table_document(lines)?;
5335
5336 let mut output = String::new();
5337 output.push_str("# ");
5338 output.push_str(report.title.trim());
5339 output.push_str("\n\n");
5340
5341 for (idx, section) in report.sections.iter().enumerate() {
5342 output.push_str("## ");
5343 output.push_str(section.heading.trim());
5344 output.push_str("\n\n");
5345 output.push_str(&render_pipe_rows(§ion.rows));
5346 if let Some(note) = §ion.note {
5347 output.push('*');
5348 output.push_str(&escape_md_line_start(note.trim()));
5349 output.push_str("*\n");
5350 }
5351 if idx + 1 != report.sections.len() {
5352 output.push('\n');
5353 }
5354 }
5355
5356 Some(output.trim_end().to_string() + "\n")
5357}
5358
5359#[cfg(not(target_arch = "wasm32"))]
5360fn detect_layout_titled_dual_table_document(
5361 lines: &[String],
5362) -> Option<LayoutTitledDualTableDocument> {
5363 let title_idx = lines
5364 .iter()
5365 .position(|line| normalize_heading_text(line.trim()) == "jailedfordoingbusiness")?;
5366 let title = lines[title_idx].trim().to_string();
5367
5368 let caption_indices = lines
5369 .iter()
5370 .enumerate()
5371 .filter_map(|(idx, line)| line.trim_start().starts_with("TABLE ").then_some(idx))
5372 .collect::<Vec<_>>();
5373 if caption_indices.len() != 2 {
5374 return None;
5375 }
5376
5377 let mut sections = Vec::new();
5378 for (section_idx, caption_idx) in caption_indices.iter().enumerate() {
5379 let next_caption_idx = caption_indices
5380 .get(section_idx + 1)
5381 .copied()
5382 .unwrap_or(lines.len());
5383
5384 let header_idx = (*caption_idx + 1..next_caption_idx).find(|idx| {
5385 let spans = split_layout_line_spans(&lines[*idx]);
5386 (spans.len() == 3 || spans.len() == 4)
5387 && spans
5388 .iter()
5389 .all(|(_, text)| text.split_whitespace().count() <= 3)
5390 })?;
5391 let note_idx = (header_idx + 1..next_caption_idx)
5392 .find(|idx| lines[*idx].trim_start().starts_with('*'))
5393 .unwrap_or(next_caption_idx);
5394
5395 let heading = (*caption_idx..header_idx)
5396 .map(|idx| lines[idx].trim())
5397 .filter(|line| !line.is_empty())
5398 .collect::<Vec<_>>()
5399 .join(" ");
5400
5401 let rows = parse_layout_titled_stub_table(lines, header_idx, note_idx)?;
5402 let note = (note_idx < next_caption_idx)
5403 .then(|| {
5404 lines[note_idx]
5405 .trim()
5406 .trim_start_matches('*')
5407 .trim()
5408 .to_string()
5409 })
5410 .filter(|text| !text.is_empty());
5411
5412 sections.push(LayoutTitledTableSection {
5413 heading,
5414 rows,
5415 note,
5416 });
5417 }
5418
5419 Some(LayoutTitledDualTableDocument { title, sections })
5420}
5421
5422#[cfg(not(target_arch = "wasm32"))]
5423fn parse_layout_titled_stub_table(
5424 lines: &[String],
5425 header_idx: usize,
5426 stop_idx: usize,
5427) -> Option<Vec<Vec<String>>> {
5428 let header_spans = split_layout_line_spans(&lines[header_idx]);
5429 if header_spans.len() < 3 {
5430 return None;
5431 }
5432
5433 let mut column_starts = vec![0usize];
5434 column_starts.extend(header_spans.iter().map(|(start, _)| *start));
5435 let mut header = vec![String::new()];
5436 header.extend(header_spans.into_iter().map(|(_, text)| text));
5437
5438 if header[0].trim().is_empty() && header.get(1).is_some_and(|cell| cell.trim() == "Range") {
5439 header.remove(0);
5440 column_starts.remove(0);
5441 }
5442
5443 let mut rows = vec![header];
5444 let mut pending_stub = String::new();
5445 let mut last_row_idx: Option<usize> = None;
5446
5447 for line in lines.iter().take(stop_idx).skip(header_idx + 1) {
5448 let spans = split_layout_line_spans(line);
5449 if spans.is_empty() {
5450 continue;
5451 }
5452
5453 let first_data_start = column_starts.get(1).copied().unwrap_or(usize::MAX);
5454 let stub_only_line = spans
5455 .iter()
5456 .all(|(start, text)| *start < first_data_start && !looks_like_layout_value(text));
5457 if stub_only_line {
5458 let stub_text = spans
5459 .iter()
5460 .map(|(_, text)| text.trim())
5461 .filter(|text| !text.is_empty())
5462 .collect::<Vec<_>>()
5463 .join(" ");
5464 if pending_stub.is_empty() && stub_text.split_whitespace().count() <= 2 {
5465 if let Some(last_idx) = last_row_idx {
5466 if rows[last_idx]
5467 .iter()
5468 .skip(1)
5469 .any(|cell| !cell.trim().is_empty())
5470 {
5471 append_cell_text(&mut rows[last_idx][0], &stub_text);
5472 continue;
5473 }
5474 }
5475 }
5476 append_cell_text(&mut pending_stub, &stub_text);
5477 continue;
5478 }
5479
5480 let row = assign_layout_spans_to_columns(&spans, &column_starts);
5481 let row_has_values = row.iter().skip(1).any(|cell| looks_like_layout_value(cell));
5482 let only_stub =
5483 !row[0].trim().is_empty() && row.iter().skip(1).all(|cell| cell.trim().is_empty());
5484
5485 if row_has_values {
5486 let mut finalized = row;
5487 if !pending_stub.is_empty() && finalized[0].trim().is_empty() {
5488 finalized[0] = pending_stub.clone();
5489 pending_stub.clear();
5490 }
5491 rows.push(finalized);
5492 last_row_idx = Some(rows.len() - 1);
5493 continue;
5494 }
5495
5496 if only_stub {
5497 if let Some(last_idx) = last_row_idx {
5498 if rows[last_idx]
5499 .iter()
5500 .skip(1)
5501 .any(|cell| !cell.trim().is_empty())
5502 {
5503 append_cell_text(&mut rows[last_idx][0], &row[0]);
5504 continue;
5505 }
5506 }
5507 append_cell_text(&mut pending_stub, &row[0]);
5508 }
5509 }
5510
5511 if rows.len() < 3 {
5512 return None;
5513 }
5514
5515 Some(rows)
5516}
5517
5518#[cfg(not(target_arch = "wasm32"))]
5519fn looks_like_layout_value(text: &str) -> bool {
5520 let trimmed = text.trim();
5521 !trimmed.is_empty()
5522 && trimmed
5523 .chars()
5524 .any(|ch| ch.is_ascii_digit() || matches!(ch, '%' | '+' | '-' | ',' | '.'))
5525}
5526
5527#[cfg(not(target_arch = "wasm32"))]
5528#[allow(dead_code)]
5529fn render_layout_registration_report_document(doc: &PdfDocument) -> Option<String> {
5530 let mut layout_cache = LayoutSourceCache::default();
5531 render_layout_registration_report_document_cached(doc, &mut layout_cache)
5532}
5533
5534#[cfg(not(target_arch = "wasm32"))]
5535fn render_layout_registration_report_document_cached(
5536 doc: &PdfDocument,
5537 layout_cache: &mut LayoutSourceCache,
5538) -> Option<String> {
5539 if doc.number_of_pages != 1 {
5540 return None;
5541 }
5542
5543 let lines = layout_cache.layout_lines(doc)?;
5544 let report = detect_layout_registration_report_document(lines)?;
5545
5546 let mut output = String::new();
5547 output.push_str("# ");
5548 output.push_str(report.title.trim());
5549 output.push_str("\n\n");
5550 output.push_str(&render_pipe_rows(&report.rows));
5551 Some(output)
5552}
5553
5554#[cfg(not(target_arch = "wasm32"))]
5555fn detect_layout_registration_report_document(
5556 lines: &[String],
5557) -> Option<LayoutRegistrationReportDocument> {
5558 let title_idx = lines.iter().position(|line| {
5559 normalize_heading_text(line.trim()) == "anfrelpreelectionassessmentmissionreport"
5560 })?;
5561 let title = lines[title_idx].trim().to_string();
5562
5563 let first_row_idx = (title_idx + 1..lines.len()).find(|idx| {
5564 lines[*idx].trim_start().starts_with("11") && lines[*idx].contains("Khmer United Party")
5565 })?;
5566 let footer_idx = (first_row_idx + 1..lines.len())
5567 .find(|idx| is_standalone_page_number(lines[*idx].trim()))
5568 .unwrap_or(lines.len());
5569
5570 let data_starts = split_layout_line_spans(&lines[first_row_idx])
5571 .into_iter()
5572 .map(|(start, _)| start)
5573 .collect::<Vec<_>>();
5574 if data_starts.len() != 7 {
5575 return None;
5576 }
5577
5578 let mut rows = vec![
5579 vec![
5580 "No.".to_string(),
5581 "Political party".to_string(),
5582 "Provisional registration result on 7 March".to_string(),
5583 String::new(),
5584 "Official registration result on 29 April".to_string(),
5585 String::new(),
5586 "Difference in the number of candidates".to_string(),
5587 ],
5588 vec![
5589 String::new(),
5590 String::new(),
5591 "Number of commune/ sangkat".to_string(),
5592 "Number of candidates".to_string(),
5593 "Number of commune/ sangkat".to_string(),
5594 "Number of candidates".to_string(),
5595 String::new(),
5596 ],
5597 ];
5598
5599 let mut current_row: Option<Vec<String>> = None;
5600 for line in lines.iter().take(footer_idx).skip(first_row_idx) {
5601 let spans = split_layout_line_spans(line);
5602 if spans.is_empty() {
5603 continue;
5604 }
5605
5606 let cells = assign_layout_spans_to_columns(&spans, &data_starts);
5607 let starts_new_row = (!cells[0].trim().is_empty()
5608 && cells[0].trim().chars().all(|ch| ch.is_ascii_digit()))
5609 || cells[0].trim() == "Total"
5610 || cells[1].trim() == "Total";
5611
5612 if starts_new_row {
5613 if let Some(row) = current_row.take() {
5614 rows.push(row);
5615 }
5616 current_row = Some(cells);
5617 continue;
5618 }
5619
5620 let Some(row) = current_row.as_mut() else {
5621 continue;
5622 };
5623 for (idx, cell) in cells.iter().enumerate() {
5624 if cell.trim().is_empty() {
5625 continue;
5626 }
5627 append_cell_text(&mut row[idx], cell);
5628 }
5629 }
5630
5631 if let Some(row) = current_row.take() {
5632 rows.push(row);
5633 }
5634 if rows.len() < 5 {
5635 return None;
5636 }
5637
5638 Some(LayoutRegistrationReportDocument { title, rows })
5639}
5640
5641#[cfg(not(target_arch = "wasm32"))]
5642fn collect_layout_caption_paragraph(lines: &[String], start_idx: usize) -> Option<String> {
5643 let mut caption_lines = Vec::new();
5644 for line in lines.iter().skip(start_idx) {
5645 let trimmed = line.trim();
5646 if trimmed.is_empty() {
5647 if !caption_lines.is_empty() {
5648 break;
5649 }
5650 continue;
5651 }
5652 if !caption_lines.is_empty() && trimmed.contains("H6 (Avg.)") && trimmed.contains("GSM8K") {
5653 break;
5654 }
5655 if !caption_lines.is_empty()
5656 && (trimmed.starts_with("Table ")
5657 || trimmed.starts_with("5 ")
5658 || trimmed == "5 Conclusion")
5659 {
5660 break;
5661 }
5662 caption_lines.push(trimmed.to_string());
5663 }
5664
5665 let paragraph = caption_lines.join(" ");
5666 (!paragraph.trim().is_empty()).then_some(paragraph)
5667}
5668
5669#[cfg(not(target_arch = "wasm32"))]
5670fn detect_layout_appendix_tables_document(
5671 lines: &[String],
5672) -> Option<LayoutAppendixTablesDocument> {
5673 let title_idx = lines
5674 .iter()
5675 .position(|line| normalize_heading_text(line.trim()) == "appendices")?;
5676 let title = lines[title_idx].trim().to_string();
5677
5678 let caption_indices = lines
5679 .iter()
5680 .enumerate()
5681 .filter_map(|(idx, line)| line.trim_start().starts_with("TABLE ").then_some(idx))
5682 .collect::<Vec<_>>();
5683 if caption_indices.len() < 2 {
5684 return None;
5685 }
5686
5687 let mut sections = Vec::new();
5688 for (pos, caption_idx) in caption_indices.iter().enumerate() {
5689 let next_caption_idx = caption_indices.get(pos + 1).copied().unwrap_or(lines.len());
5690
5691 let mut heading_lines = vec![lines[*caption_idx].trim().to_string()];
5692 let mut cursor = caption_idx + 1;
5693 while cursor < next_caption_idx {
5694 let trimmed = lines[cursor].trim();
5695 if trimmed.is_empty() {
5696 cursor += 1;
5697 continue;
5698 }
5699 let spans = split_layout_line_spans(&lines[cursor]);
5700 let looks_like_caption_continuation = spans.len() == 1
5701 && spans[0].0 <= 4
5702 && !trimmed.starts_with("Source")
5703 && !trimmed.starts_with("Sources")
5704 && !trimmed.starts_with("Exchange rate")
5705 && !trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit())
5706 && trimmed
5707 .chars()
5708 .all(|ch| !ch.is_alphabetic() || ch.is_uppercase());
5709 if !looks_like_caption_continuation {
5710 break;
5711 }
5712 heading_lines.push(trimmed.to_string());
5713 cursor += 1;
5714 }
5715
5716 let data_start = (*caption_idx + 1..next_caption_idx).find(|idx| {
5717 let trimmed = lines[*idx].trim();
5718 !trimmed.is_empty()
5719 && !trimmed.starts_with("Source")
5720 && !trimmed.starts_with("Sources")
5721 && !trimmed.starts_with("Exchange rate")
5722 && split_layout_line_spans(&lines[*idx]).len() == 4
5723 })?;
5724
5725 let note_start = (data_start..next_caption_idx).find(|idx| {
5726 let trimmed = lines[*idx].trim();
5727 trimmed.starts_with("Source")
5728 || trimmed.starts_with("Sources")
5729 || trimmed.starts_with("Exchange rate")
5730 });
5731 let data_end = note_start.unwrap_or(next_caption_idx);
5732 let first_row_spans = split_layout_line_spans(&lines[data_start]);
5733 if first_row_spans.len() != 4 {
5734 return None;
5735 }
5736 let column_starts = first_row_spans
5737 .iter()
5738 .map(|(start, _)| *start)
5739 .collect::<Vec<_>>();
5740
5741 let mut header_cells = vec![String::new(); column_starts.len()];
5742 for line in lines.iter().take(data_start).skip(cursor) {
5743 for (start, text) in split_layout_line_spans(line) {
5744 let Some((col_idx, _)) = column_starts
5745 .iter()
5746 .enumerate()
5747 .min_by_key(|(_, col_start)| start.abs_diff(**col_start))
5748 else {
5749 continue;
5750 };
5751 append_cell_text(&mut header_cells[col_idx], &text);
5752 }
5753 }
5754 if header_cells.iter().any(|cell| cell.trim().is_empty()) {
5755 continue;
5756 }
5757
5758 let mut rows = vec![header_cells];
5759 for line in lines.iter().take(data_end).skip(data_start) {
5760 let spans = split_layout_line_spans(line);
5761 if spans.len() != 4 {
5762 continue;
5763 }
5764 let mut row = vec![String::new(); column_starts.len()];
5765 for (start, text) in spans {
5766 let Some((col_idx, _)) = column_starts
5767 .iter()
5768 .enumerate()
5769 .min_by_key(|(_, col_start)| start.abs_diff(**col_start))
5770 else {
5771 continue;
5772 };
5773 append_cell_text(&mut row[col_idx], &text);
5774 }
5775 if row.iter().all(|cell| !cell.trim().is_empty()) {
5776 rows.push(row);
5777 }
5778 }
5779 if rows.len() < 3 {
5780 continue;
5781 }
5782
5783 let notes = lines
5784 .iter()
5785 .take(next_caption_idx)
5786 .skip(note_start.unwrap_or(next_caption_idx))
5787 .map(|line| line.trim())
5788 .filter(|line| {
5789 !line.is_empty()
5790 && !line.chars().all(|ch| ch.is_ascii_digit())
5791 && !is_standalone_page_number(line)
5792 })
5793 .map(str::to_string)
5794 .collect::<Vec<_>>();
5795
5796 sections.push(LayoutAppendixTableSection {
5797 heading: heading_lines.join(" "),
5798 rows,
5799 notes,
5800 });
5801 }
5802
5803 (sections.len() >= 2).then_some(LayoutAppendixTablesDocument { title, sections })
5804}
5805
5806#[cfg(not(target_arch = "wasm32"))]
5807fn read_pdftotext_layout_lines(path: &Path) -> Option<Vec<String>> {
5808 let output = Command::new("pdftotext")
5809 .arg("-layout")
5810 .arg(path)
5811 .arg("-")
5812 .output()
5813 .ok()?;
5814 if !output.status.success() {
5815 return None;
5816 }
5817 Some(
5818 String::from_utf8_lossy(&output.stdout)
5819 .lines()
5820 .map(|line| line.to_string())
5821 .collect(),
5822 )
5823}
5824
5825#[cfg(not(target_arch = "wasm32"))]
5826fn find_layout_header_candidate(lines: &[String]) -> Option<LayoutHeaderCandidate> {
5827 lines.iter().enumerate().find_map(|(line_idx, line)| {
5828 let spans = split_layout_line_spans(line);
5829 if spans.len() != 4 {
5830 return None;
5831 }
5832 let headers: Vec<String> = spans.iter().map(|(_, text)| text.clone()).collect();
5833 let starts: Vec<usize> = spans.iter().map(|(start, _)| *start).collect();
5834 let short_headers = headers
5835 .iter()
5836 .all(|text| text.split_whitespace().count() <= 3 && text.len() <= 24);
5837 let increasing = starts.windows(2).all(|pair| pair[1] > pair[0] + 6);
5838 (short_headers && increasing).then_some(LayoutHeaderCandidate {
5839 line_idx,
5840 headers,
5841 starts,
5842 })
5843 })
5844}
5845
5846#[cfg(not(target_arch = "wasm32"))]
5847fn find_layout_panel_header_candidate(lines: &[String]) -> Option<LayoutPanelHeaderCandidate> {
5848 lines.iter().enumerate().find_map(|(line_idx, line)| {
5849 let spans = split_layout_line_spans(line);
5850 if spans.len() != 3 {
5851 return None;
5852 }
5853
5854 let headers: Vec<String> = spans.iter().map(|(_, text)| text.clone()).collect();
5855 let starts: Vec<usize> = spans.iter().map(|(start, _)| *start).collect();
5856 let header_like = headers
5857 .iter()
5858 .all(|text| text.split_whitespace().count() <= 4 && text.len() <= 32);
5859 let increasing = starts.windows(2).all(|pair| pair[1] > pair[0] + 16);
5860 (header_like && increasing).then_some(LayoutPanelHeaderCandidate {
5861 line_idx,
5862 headers,
5863 starts,
5864 })
5865 })
5866}
5867
5868#[cfg(not(target_arch = "wasm32"))]
5869fn split_layout_line_spans(line: &str) -> Vec<(usize, String)> {
5870 let chars = line.chars().collect::<Vec<_>>();
5871 let mut spans = Vec::new();
5872 let mut idx = 0usize;
5873 while idx < chars.len() {
5874 while idx < chars.len() && chars[idx].is_whitespace() {
5875 idx += 1;
5876 }
5877 if idx >= chars.len() {
5878 break;
5879 }
5880
5881 let start = idx;
5882 let mut end = idx;
5883 let mut gap = 0usize;
5884 while end < chars.len() {
5885 if chars[end].is_whitespace() {
5886 gap += 1;
5887 if gap >= 2 {
5888 break;
5889 }
5890 } else {
5891 gap = 0;
5892 }
5893 end += 1;
5894 }
5895 let text = slice_layout_column_text(line, start, end);
5896 if !text.is_empty() {
5897 spans.push((start, text));
5898 }
5899 idx = end.saturating_add(gap);
5900 }
5901 spans
5902}
5903
5904#[cfg(not(target_arch = "wasm32"))]
5905fn slice_layout_column_text(line: &str, start: usize, end: usize) -> String {
5906 line.chars()
5907 .skip(start)
5908 .take(end.saturating_sub(start))
5909 .collect::<String>()
5910 .trim()
5911 .to_string()
5912}
5913
5914#[cfg(not(target_arch = "wasm32"))]
5915fn extract_layout_entries(lines: &[String], header: &LayoutHeaderCandidate) -> Vec<LayoutEntry> {
5916 let mut entries = Vec::new();
5917 let mut next_starts = header.starts.iter().copied().skip(1).collect::<Vec<_>>();
5918 next_starts.push(usize::MAX);
5919
5920 for (line_idx, line) in lines.iter().enumerate().skip(header.line_idx + 1) {
5921 if line.contains('\u{c}') {
5922 break;
5923 }
5924 let cells = header
5925 .starts
5926 .iter()
5927 .copied()
5928 .zip(next_starts.iter().copied())
5929 .map(|(start, next_start)| {
5930 let char_count = line.chars().count();
5931 if start >= char_count {
5932 String::new()
5933 } else {
5934 let end = next_start.min(char_count);
5935 normalize_layout_matrix_text(&slice_layout_column_text(line, start, end))
5936 }
5937 })
5938 .collect::<Vec<_>>();
5939 if cells.iter().any(|cell| !cell.is_empty()) {
5940 entries.push(LayoutEntry { line_idx, cells });
5941 }
5942 }
5943
5944 entries
5945}
5946
5947#[cfg(not(target_arch = "wasm32"))]
5948fn build_layout_panel_stub_rows(
5949 lines: &[String],
5950 header: &LayoutPanelHeaderCandidate,
5951) -> Option<Vec<Vec<String>>> {
5952 let body_starts = infer_layout_panel_body_starts(lines, header)?;
5953 let mut starts = vec![0usize];
5954 starts.extend(body_starts.iter().copied());
5955 let mut next_starts = starts.iter().copied().skip(1).collect::<Vec<_>>();
5956 next_starts.push(usize::MAX);
5957
5958 let mut entries = Vec::<LayoutEntry>::new();
5959 for (line_idx, line) in lines.iter().enumerate().skip(header.line_idx + 1) {
5960 if line.contains('\u{c}') {
5961 break;
5962 }
5963 let trimmed = line.trim();
5964 if trimmed.is_empty() {
5965 continue;
5966 }
5967 if trimmed.chars().all(|ch| ch.is_ascii_digit()) && trimmed.len() <= 4 {
5968 continue;
5969 }
5970
5971 let cells = starts
5972 .iter()
5973 .copied()
5974 .zip(next_starts.iter().copied())
5975 .map(|(start, next_start)| {
5976 let char_count = line.chars().count();
5977 if start >= char_count {
5978 String::new()
5979 } else {
5980 let end = next_start.min(char_count);
5981 normalize_layout_matrix_text(&slice_layout_column_text(line, start, end))
5982 }
5983 })
5984 .collect::<Vec<_>>();
5985 if cells.iter().any(|cell| !cell.is_empty()) {
5986 entries.push(LayoutEntry { line_idx, cells });
5987 }
5988 }
5989
5990 let stub_threshold = body_starts[0].saturating_div(2).max(6);
5991 let anchor_indices = entries
5992 .iter()
5993 .filter(|entry| {
5994 let spans = split_layout_line_spans(&lines[entry.line_idx]);
5995 spans.first().is_some_and(|(start, text)| {
5996 *start <= stub_threshold
5997 && !text.trim().is_empty()
5998 && text.split_whitespace().count() <= 3
5999 && text.len() <= 24
6000 })
6001 })
6002 .map(|entry| entry.line_idx)
6003 .collect::<Vec<_>>();
6004 if anchor_indices.len() < 2 {
6005 return None;
6006 }
6007
6008 let mut rows = anchor_indices
6009 .iter()
6010 .map(|line_idx| {
6011 let anchor = entries
6012 .iter()
6013 .find(|entry| entry.line_idx == *line_idx)
6014 .expect("anchor index should exist");
6015 let mut row = vec![String::new(); anchor.cells.len()];
6016 row[0] = anchor.cells[0].clone();
6017 row
6018 })
6019 .collect::<Vec<_>>();
6020
6021 for entry in entries {
6022 let row_idx = anchor_indices
6023 .iter()
6024 .enumerate()
6025 .min_by_key(|(_, anchor_idx)| anchor_idx.abs_diff(entry.line_idx))
6026 .map(|(idx, _)| idx)?;
6027
6028 for col_idx in 0..rows[row_idx].len().min(entry.cells.len()) {
6029 if col_idx == 0 && anchor_indices[row_idx] == entry.line_idx {
6030 continue;
6031 }
6032 append_cell_text(&mut rows[row_idx][col_idx], &entry.cells[col_idx]);
6033 }
6034 }
6035
6036 let normalized_rows = rows
6037 .into_iter()
6038 .map(|mut row| {
6039 row[0] = normalize_layout_stage_text(&row[0]);
6040 row[1] = normalize_layout_body_text(&row[1]);
6041 row[2] = normalize_layout_body_text(&row[2]);
6042 row[3] = normalize_layout_body_text(&row[3]);
6043 row
6044 })
6045 .filter(|row| row.iter().skip(1).any(|cell| !cell.trim().is_empty()))
6046 .collect::<Vec<_>>();
6047 Some(normalized_rows)
6048}
6049
6050#[cfg(not(target_arch = "wasm32"))]
6051fn infer_layout_panel_body_starts(
6052 lines: &[String],
6053 header: &LayoutPanelHeaderCandidate,
6054) -> Option<Vec<usize>> {
6055 let mut candidates = Vec::<[usize; 3]>::new();
6056 for line in lines.iter().skip(header.line_idx + 1) {
6057 if line.contains('\u{c}') {
6058 break;
6059 }
6060 let spans = split_layout_line_spans(line);
6061 if spans.len() < 2 {
6062 continue;
6063 }
6064
6065 let last_three = spans
6066 .iter()
6067 .rev()
6068 .take(3)
6069 .map(|(start, _)| *start)
6070 .collect::<Vec<_>>();
6071 if last_three.len() != 3 {
6072 continue;
6073 }
6074
6075 let mut starts = last_three;
6076 starts.reverse();
6077 if starts[0] >= header.starts[0] {
6078 continue;
6079 }
6080 if !(starts[0] < starts[1] && starts[1] < starts[2]) {
6081 continue;
6082 }
6083 candidates.push([starts[0], starts[1], starts[2]]);
6084 }
6085
6086 if candidates.len() < 3 {
6087 return None;
6088 }
6089
6090 Some(
6091 (0..3)
6092 .map(|col_idx| {
6093 candidates
6094 .iter()
6095 .map(|starts| starts[col_idx])
6096 .min()
6097 .unwrap_or(0)
6098 })
6099 .collect(),
6100 )
6101}
6102
6103#[cfg(not(target_arch = "wasm32"))]
6104fn build_layout_anchor_rows(
6105 raw_lines: &[String],
6106 entries: &[LayoutEntry],
6107) -> Option<Vec<Vec<String>>> {
6108 let mut rows = Vec::<LayoutAnchorRow>::new();
6109 let mut anchor_members = Vec::<usize>::new();
6110
6111 for entry in entries {
6112 if entry.cells.get(1).is_none_or(|cell| cell.is_empty()) {
6113 continue;
6114 }
6115
6116 if let Some(previous) = rows.last_mut() {
6117 let distance = entry.line_idx.saturating_sub(previous.last_anchor_idx);
6118 let stage_empty = entry.cells.first().is_none_or(|cell| cell.is_empty());
6119 let body_empty = entry
6120 .cells
6121 .iter()
6122 .skip(2)
6123 .all(|cell| cell.trim().is_empty());
6124 if stage_empty && distance <= 2 && !previous.cells[0].trim().is_empty() {
6125 merge_layout_row_cells(&mut previous.cells, &entry.cells);
6126 previous.last_anchor_idx = entry.line_idx;
6127 anchor_members.push(entry.line_idx);
6128 continue;
6129 }
6130 if stage_empty && body_empty && distance <= 3 {
6131 append_cell_text(&mut previous.cells[1], &entry.cells[1]);
6132 previous.last_anchor_idx = entry.line_idx;
6133 anchor_members.push(entry.line_idx);
6134 continue;
6135 }
6136 }
6137
6138 rows.push(LayoutAnchorRow {
6139 anchor_idx: entry.line_idx,
6140 last_anchor_idx: entry.line_idx,
6141 cells: entry.cells.clone(),
6142 });
6143 anchor_members.push(entry.line_idx);
6144 }
6145
6146 if rows.len() < 4 {
6147 return None;
6148 }
6149
6150 let anchor_indices = rows.iter().map(|row| row.anchor_idx).collect::<Vec<_>>();
6151
6152 for entry in entries {
6153 if anchor_members.contains(&entry.line_idx) {
6154 continue;
6155 }
6156
6157 let next_pos = anchor_indices
6158 .iter()
6159 .position(|anchor| *anchor > entry.line_idx);
6160 let prev_pos = next_pos
6161 .map(|pos| pos.saturating_sub(1))
6162 .unwrap_or(rows.len().saturating_sub(1));
6163
6164 let target = if let Some(next_pos) = next_pos {
6165 let previous_line_blank = entry
6166 .line_idx
6167 .checked_sub(1)
6168 .and_then(|idx| raw_lines.get(idx))
6169 .is_some_and(|line| line.trim().is_empty());
6170 let filled_slots = entry
6171 .cells
6172 .iter()
6173 .enumerate()
6174 .filter_map(|(idx, cell)| (!cell.is_empty()).then_some(idx))
6175 .collect::<Vec<_>>();
6176 let prev_stage_empty = rows[prev_pos].cells[0].trim().is_empty();
6177 let next_stage_empty = rows[next_pos].cells[0].trim().is_empty();
6178
6179 if (previous_line_blank && anchor_indices[next_pos].saturating_sub(entry.line_idx) <= 1)
6180 || (filled_slots == [3]
6181 && anchor_indices[next_pos].saturating_sub(entry.line_idx) <= 1
6182 && !rows[prev_pos].cells[3].trim().is_empty())
6183 {
6184 next_pos
6185 } else if prev_stage_empty && next_stage_empty {
6186 let next_distance = anchor_indices[next_pos].abs_diff(entry.line_idx);
6187 let prev_distance = anchor_indices[prev_pos].abs_diff(entry.line_idx);
6188 if next_distance < prev_distance {
6189 next_pos
6190 } else {
6191 prev_pos
6192 }
6193 } else {
6194 prev_pos
6195 }
6196 } else {
6197 prev_pos
6198 };
6199
6200 merge_layout_row_cells(&mut rows[target].cells, &entry.cells);
6201 }
6202
6203 let normalized_rows = rows
6204 .into_iter()
6205 .map(|mut row| {
6206 row.cells[0] = normalize_layout_stage_text(&row.cells[0]);
6207 row.cells[1] = normalize_layout_stage_text(&row.cells[1]);
6208 row.cells[2] = normalize_layout_body_text(&row.cells[2]);
6209 row.cells[3] = normalize_layout_body_text(&row.cells[3]);
6210 row.cells
6211 })
6212 .collect::<Vec<_>>();
6213
6214 Some(normalized_rows)
6215}
6216
6217#[cfg(not(target_arch = "wasm32"))]
6218fn merge_layout_row_cells(target: &mut [String], source: &[String]) {
6219 for (target_cell, source_cell) in target.iter_mut().zip(source.iter()) {
6220 append_cell_text(target_cell, source_cell);
6221 }
6222}
6223
6224#[cfg(not(target_arch = "wasm32"))]
6225fn normalize_layout_matrix_text(text: &str) -> String {
6226 collapse_inline_whitespace(text)
6227}
6228
6229#[cfg(not(target_arch = "wasm32"))]
6230fn normalize_layout_stage_text(text: &str) -> String {
6231 collapse_inline_whitespace(text)
6232}
6233
6234#[cfg(not(target_arch = "wasm32"))]
6235fn normalize_layout_body_text(text: &str) -> String {
6236 let tokens = text
6237 .split_whitespace()
6238 .filter(|token| {
6239 let bare = token.trim_matches(|ch: char| !ch.is_alphanumeric());
6240 !(bare.len() == 1 && bare.chars().all(|ch| ch.is_ascii_digit()))
6241 })
6242 .collect::<Vec<_>>();
6243 if tokens.is_empty() {
6244 return String::new();
6245 }
6246 collapse_inline_whitespace(&tokens.join(" "))
6247}
6248
6249fn first_heading_like_text(doc: &PdfDocument) -> Option<String> {
6250 for (idx, element) in doc.kids.iter().enumerate().take(8) {
6251 match element {
6252 ContentElement::Heading(h) => {
6253 let text = h.base.base.value();
6254 let trimmed = text.trim();
6255 if !trimmed.is_empty() {
6256 return Some(trimmed.to_string());
6257 }
6258 }
6259 ContentElement::NumberHeading(nh) => {
6260 let text = nh.base.base.base.value();
6261 let trimmed = text.trim();
6262 if !trimmed.is_empty() {
6263 return Some(trimmed.to_string());
6264 }
6265 }
6266 ContentElement::Paragraph(p) => {
6267 let text = clean_paragraph_text(&p.base.value());
6268 let trimmed = text.trim();
6269 if should_render_paragraph_as_heading(doc, idx, trimmed, doc.kids.get(idx + 1)) {
6270 return Some(trimmed.to_string());
6271 }
6272 }
6273 ContentElement::TextBlock(tb) => {
6274 let text = clean_paragraph_text(&tb.value());
6275 let trimmed = text.trim();
6276 if should_render_paragraph_as_heading(doc, idx, trimmed, doc.kids.get(idx + 1)) {
6277 return Some(trimmed.to_string());
6278 }
6279 }
6280 ContentElement::TextLine(tl) => {
6281 let text = clean_paragraph_text(&tl.value());
6282 let trimmed = text.trim();
6283 if should_render_paragraph_as_heading(doc, idx, trimmed, doc.kids.get(idx + 1)) {
6284 return Some(trimmed.to_string());
6285 }
6286 }
6287 _ => {}
6288 }
6289 }
6290 None
6291}
6292
6293fn equivalent_heading_text(left: &str, right: &str) -> bool {
6294 normalize_heading_text(left) == normalize_heading_text(right)
6295}
6296
6297fn normalize_heading_text(text: &str) -> String {
6298 text.chars()
6299 .filter(|ch| ch.is_alphanumeric())
6300 .flat_map(char::to_lowercase)
6301 .collect()
6302}
6303
6304fn looks_like_contents_document(doc: &PdfDocument) -> bool {
6305 let Some(first) = first_heading_like_text(doc) else {
6306 return false;
6307 };
6308 if !matches!(
6309 normalize_heading_text(&first).as_str(),
6310 "contents" | "tableofcontents"
6311 ) {
6312 return false;
6313 }
6314
6315 let lines = collect_plain_lines(doc);
6316 if lines.len() < 8 {
6317 return false;
6318 }
6319
6320 let page_like = lines
6321 .iter()
6322 .skip(1)
6323 .filter(|line| ends_with_page_marker(line))
6324 .count();
6325 page_like * 10 >= (lines.len().saturating_sub(1)).max(1) * 6
6326}
6327
6328fn render_contents_document(doc: &PdfDocument) -> String {
6329 render_toc_lines(&collect_plain_lines(doc), true)
6330}
6331
6332fn looks_like_compact_toc_document(doc: &PdfDocument) -> bool {
6333 let lines = collect_plain_lines(doc);
6334 if lines.len() < 8 {
6335 return false;
6336 }
6337
6338 let page_like = lines
6339 .iter()
6340 .filter(|line| ends_with_page_marker(line))
6341 .count();
6342 let support_like = lines
6343 .iter()
6344 .filter(|line| looks_like_toc_support_heading(line))
6345 .count();
6346
6347 page_like >= 3 && support_like >= 2 && (page_like + support_like) * 10 >= lines.len() * 8
6348}
6349
6350fn render_compact_toc_document(doc: &PdfDocument) -> String {
6351 render_toc_lines(&collect_plain_lines(doc), false)
6352}
6353
6354fn render_toc_lines(lines: &[String], has_contents_title: bool) -> String {
6355 let mut out = String::new();
6356 let mut iter = lines.iter();
6357
6358 if has_contents_title {
6359 if let Some(first) = iter.next() {
6360 let trimmed = first.trim();
6361 if !trimmed.is_empty() {
6362 push_toc_heading(&mut out, 1, trimmed);
6363 }
6364 }
6365 }
6366
6367 for line in iter {
6368 let trimmed = line.trim();
6369 if trimmed.is_empty() {
6370 continue;
6371 }
6372
6373 if !out.ends_with('\n') && !out.is_empty() {
6376 out.push('\n');
6377 }
6378 out.push_str(&escape_md_line_start(trimmed));
6379 out.push('\n');
6380 }
6381
6382 out.push('\n');
6383 out
6384}
6385
6386#[allow(dead_code)]
6387fn toc_heading_level(text: &str, has_contents_title: bool) -> Option<usize> {
6388 let trimmed = strip_trailing_page_number(text).trim();
6389 let lower = trimmed.to_ascii_lowercase();
6390
6391 if has_contents_title {
6392 if lower.starts_with("part ")
6393 || lower.starts_with("chapter ")
6394 || lower.starts_with("appendix ")
6395 {
6396 return Some(2);
6397 }
6398 return None;
6399 }
6400
6401 if lower.starts_with("part ") || lower.starts_with("chapter ") || lower.starts_with("appendix ")
6402 {
6403 return Some(1);
6404 }
6405 if lower.starts_with("section ") {
6406 return Some(2);
6407 }
6408 None
6409}
6410
6411#[allow(dead_code)]
6412fn should_render_toc_line_as_bullet(text: &str, has_contents_title: bool) -> bool {
6413 has_contents_title && ends_with_page_marker(text) && toc_heading_level(text, true).is_none()
6414}
6415
6416fn push_toc_heading(out: &mut String, level: usize, text: &str) {
6417 let trimmed = text.trim();
6418 if trimmed.is_empty() {
6419 return;
6420 }
6421
6422 if !out.is_empty() && !out.ends_with("\n\n") {
6423 out.push('\n');
6424 }
6425 out.push_str(&"#".repeat(level));
6426 out.push(' ');
6427 out.push_str(trimmed);
6428 out.push_str("\n\n");
6429}
6430
6431fn collect_plain_lines(doc: &PdfDocument) -> Vec<String> {
6432 let mut lines = Vec::new();
6433 for element in &doc.kids {
6434 match element {
6435 ContentElement::Heading(h) => {
6436 let text = clean_paragraph_text(&h.base.base.value());
6437 if !text.trim().is_empty() {
6438 lines.push(text);
6439 }
6440 }
6441 ContentElement::NumberHeading(nh) => {
6442 let text = clean_paragraph_text(&nh.base.base.base.value());
6443 if !text.trim().is_empty() {
6444 lines.push(text);
6445 }
6446 }
6447 ContentElement::Paragraph(p) => {
6448 let text = clean_paragraph_text(&p.base.value());
6449 if !text.trim().is_empty() {
6450 lines.push(text);
6451 }
6452 }
6453 ContentElement::TextBlock(tb) => {
6454 let text = clean_paragraph_text(&tb.value());
6455 if !text.trim().is_empty() {
6456 lines.push(text);
6457 }
6458 }
6459 ContentElement::TextLine(tl) => {
6460 let text = clean_paragraph_text(&tl.value());
6461 if !text.trim().is_empty() {
6462 lines.push(text);
6463 }
6464 }
6465 ContentElement::List(list) => {
6466 for item in &list.list_items {
6467 let label = token_rows_text(&item.label.content);
6468 let body = token_rows_text(&item.body.content);
6469 let combined = if !label.trim().is_empty() && !body.trim().is_empty() {
6470 format!("{} {}", label.trim(), body.trim())
6471 } else if !body.trim().is_empty() {
6472 body.trim().to_string()
6473 } else if !label.trim().is_empty() {
6474 label.trim().to_string()
6475 } else {
6476 list_item_text_from_contents(&item.contents)
6477 .trim()
6478 .to_string()
6479 };
6480 if !combined.trim().is_empty() {
6481 lines.push(combined);
6482 }
6483 }
6484 }
6485 ContentElement::Table(table) => {
6486 extend_contents_lines_from_rows(
6487 &mut lines,
6488 collect_rendered_table_rows(
6489 &table.table_border.rows,
6490 table.table_border.num_columns,
6491 ),
6492 );
6493 }
6494 ContentElement::TableBorder(table) => {
6495 extend_contents_lines_from_rows(
6496 &mut lines,
6497 collect_rendered_table_rows(&table.rows, table.num_columns),
6498 );
6499 }
6500 _ => {}
6501 }
6502 }
6503 lines
6504}
6505
6506fn extend_contents_lines_from_rows(lines: &mut Vec<String>, rows: Vec<Vec<String>>) {
6507 if rows.is_empty() {
6508 return;
6509 }
6510
6511 if is_toc_table(&rows) {
6512 for row in &rows {
6513 let title = row.first().map(|s| s.trim()).unwrap_or("");
6514 let page = row.get(1).map(|s| s.trim()).unwrap_or("");
6515 let combined = if !title.is_empty() && !page.is_empty() {
6516 format!("{title} {page}")
6517 } else {
6518 format!("{title}{page}")
6519 };
6520 if !combined.trim().is_empty() {
6521 lines.push(combined);
6522 }
6523 }
6524 } else {
6525 for row in &rows {
6527 let combined: String = row
6528 .iter()
6529 .map(|c| c.trim())
6530 .filter(|c| !c.is_empty())
6531 .collect::<Vec<_>>()
6532 .join(" ");
6533 if !combined.is_empty() {
6534 lines.push(combined);
6535 }
6536 }
6537 }
6538}
6539
6540fn collect_rendered_table_rows(
6541 rows: &[crate::models::table::TableBorderRow],
6542 num_cols: usize,
6543) -> Vec<Vec<String>> {
6544 let num_cols = num_cols.max(1);
6545 let mut rendered_rows: Vec<Vec<String>> = Vec::new();
6546
6547 for row in rows {
6548 let cell_texts: Vec<String> = (0..num_cols)
6549 .map(|col| {
6550 row.cells
6551 .iter()
6552 .find(|c| c.col_number == col)
6553 .map(cell_text_content)
6554 .unwrap_or_default()
6555 })
6556 .collect();
6557 if !cell_texts.iter().all(|t| t.trim().is_empty()) {
6558 rendered_rows.push(cell_texts);
6559 }
6560 }
6561
6562 rendered_rows
6563}
6564
6565fn ends_with_page_marker(text: &str) -> bool {
6566 text.split_whitespace()
6567 .last()
6568 .is_some_and(is_page_number_like)
6569}
6570
6571fn looks_like_toc_support_heading(text: &str) -> bool {
6572 let trimmed = text.trim();
6573 if trimmed.is_empty() || ends_with_page_marker(trimmed) {
6574 return false;
6575 }
6576 if trimmed.ends_with(['.', ';', ':', '?', '!']) {
6577 return false;
6578 }
6579
6580 let lower = trimmed.to_ascii_lowercase();
6581 if !(lower.starts_with("part ")
6582 || lower.starts_with("chapter ")
6583 || lower.starts_with("appendix ")
6584 || lower.starts_with("section "))
6585 {
6586 return false;
6587 }
6588
6589 let word_count = trimmed.split_whitespace().count();
6590 (2..=16).contains(&word_count) && trimmed.chars().any(char::is_alphabetic)
6591}
6592
6593fn split_leading_caption_and_body(text: &str) -> Option<(&str, &str)> {
6594 if !starts_with_caption_prefix(text) || !text.contains("(credit") {
6595 return None;
6596 }
6597
6598 for needle in [") ", ". "] {
6599 let mut search_start = 0usize;
6600 while let Some(rel_idx) = text[search_start..].find(needle) {
6601 let boundary = search_start + rel_idx + needle.len() - 1;
6602 let head = text[..=boundary].trim();
6603 let tail = text[boundary + 1..].trim_start();
6604 search_start = boundary + 1;
6605 if head.split_whitespace().count() < 10 || head.split_whitespace().count() > 80 {
6606 continue;
6607 }
6608 if tail.split_whitespace().count() < 10 {
6609 continue;
6610 }
6611 if !starts_with_uppercase_word(tail) || starts_with_caption_prefix(tail) {
6612 continue;
6613 }
6614 return Some((head, tail));
6615 }
6616 }
6617
6618 None
6619}
6620
6621fn is_short_caption_label(text: &str) -> bool {
6622 if !starts_with_caption_prefix(text) {
6623 return false;
6624 }
6625
6626 let trimmed = text.trim();
6627 trimmed.split_whitespace().count() <= 3 && trimmed.len() <= 24 && !trimmed.ends_with(['.', ':'])
6628}
6629
6630fn split_following_caption_tail_and_body(text: &str) -> Option<(&str, &str)> {
6631 let trimmed = text.trim();
6632 if trimmed.is_empty()
6633 || starts_with_caption_prefix(trimmed)
6634 || !starts_with_uppercase_word(trimmed)
6635 {
6636 return None;
6637 }
6638
6639 for starter in [
6640 " As ", " In ", " The ", " This ", " These ", " It ", " They ", " We ", " On ", " At ",
6641 ] {
6642 if let Some(idx) = text.find(starter) {
6643 let head = text[..idx].trim();
6644 let tail = text[idx + 1..].trim();
6645 if head.split_whitespace().count() >= 3
6646 && head.split_whitespace().count() <= 24
6647 && tail.split_whitespace().count() >= 8
6648 {
6649 return Some((head, tail));
6650 }
6651 }
6652 }
6653
6654 None
6655}
6656
6657fn looks_like_caption_tail(text: &str) -> bool {
6658 let trimmed = text.trim();
6659 if trimmed.is_empty() || trimmed.ends_with(['.', '!', '?']) {
6660 return false;
6661 }
6662
6663 let word_count = trimmed.split_whitespace().count();
6664 if !(3..=18).contains(&word_count) {
6665 return false;
6666 }
6667
6668 starts_with_uppercase_word(trimmed)
6669 && !starts_with_caption_prefix(trimmed)
6670 && !trimmed.contains(':')
6671}
6672
6673fn looks_like_caption_year(text: &str) -> bool {
6674 let trimmed = text.trim();
6675 trimmed.len() == 4 && trimmed.chars().all(|ch| ch.is_ascii_digit())
6676}
6677
6678fn token_rows_text(rows: &[TableTokenRow]) -> String {
6680 normalize_common_ocr_text(&repair_fragmented_words(
6681 &rows
6682 .iter()
6683 .flat_map(|row| row.iter())
6684 .map(|token| token.base.value.as_str())
6685 .collect::<Vec<_>>()
6686 .join(" "),
6687 ))
6688}
6689
6690fn render_element(out: &mut String, element: &ContentElement) {
6691 match element {
6692 ContentElement::Heading(h) => {
6693 let text = h.base.base.value();
6694 let trimmed = text.trim();
6695 if should_skip_heading_text(trimmed) {
6696 return;
6697 }
6698 out.push_str(&format!("# {}\n\n", trimmed));
6699 }
6700 ContentElement::Paragraph(p) => {
6701 let text = p.base.value();
6702 let trimmed = clean_paragraph_text(&text);
6703 if !trimmed.is_empty() {
6704 out.push_str(&escape_md_line_start(&trimmed));
6705 if p.base.semantic_type == SemanticType::TableOfContent {
6706 out.push('\n');
6707 } else {
6708 out.push_str("\n\n");
6709 }
6710 }
6711 }
6712 ContentElement::List(list) => {
6713 let mut i = 0usize;
6714 let mut pending_item: Option<String> = None;
6715 while i < list.list_items.len() {
6716 let item = &list.list_items[i];
6717 let label = token_rows_text(&item.label.content);
6718 let body = token_rows_text(&item.body.content);
6719 let label_trimmed = normalize_list_text(label.trim());
6720 let body_trimmed = normalize_list_text(body.trim());
6721 let combined = if !label_trimmed.is_empty() && !body_trimmed.is_empty() {
6722 format!("{label_trimmed} {body_trimmed}")
6723 } else if !body_trimmed.is_empty() {
6724 body_trimmed.to_string()
6725 } else {
6726 label_trimmed.to_string()
6727 };
6728 let combined = if combined.trim().is_empty() && !item.contents.is_empty() {
6729 list_item_text_from_contents(&item.contents)
6730 } else {
6731 combined
6732 };
6733
6734 if is_list_section_heading(&combined) {
6735 if let Some(pending) = pending_item.take() {
6736 push_rendered_list_item(out, pending.trim());
6737 }
6738 out.push_str(&format!("# {}\n\n", combined.trim_end_matches(':').trim()));
6739 i += 1;
6740 continue;
6741 }
6742
6743 if is_pure_bullet_marker(&label_trimmed) && body_trimmed.is_empty() {
6744 i += 1;
6745 continue;
6746 }
6747
6748 if looks_like_stray_list_page_number(&combined) {
6749 i += 1;
6750 continue;
6751 }
6752
6753 let current_item = if !label_trimmed.is_empty() || !body_trimmed.is_empty() {
6754 if !label_trimmed.is_empty()
6755 && !body_trimmed.is_empty()
6756 && !is_pure_bullet_marker(&label_trimmed)
6757 {
6758 format!("{label_trimmed} {body_trimmed}")
6759 } else if !body_trimmed.is_empty() {
6760 body_trimmed.to_string()
6761 } else if !is_pure_bullet_marker(&label_trimmed) {
6762 label_trimmed.to_string()
6763 } else {
6764 String::new()
6765 }
6766 } else if !item.contents.is_empty() {
6767 normalize_list_text(list_item_text_from_contents(&item.contents).trim())
6768 } else {
6769 String::new()
6770 };
6771
6772 if current_item.is_empty() {
6773 i += 1;
6774 continue;
6775 }
6776
6777 if let Some(previous) = pending_item.as_mut() {
6778 if should_merge_list_continuation(previous, ¤t_item) {
6779 merge_paragraph_text(previous, ¤t_item);
6780 i += 1;
6781 continue;
6782 }
6783 }
6784
6785 if let Some(pending) = pending_item.replace(current_item) {
6786 push_rendered_list_item(out, pending.trim());
6787 }
6788 i += 1;
6789 }
6790 if let Some(pending) = pending_item.take() {
6791 push_rendered_list_item(out, pending.trim());
6792 }
6793 out.push('\n');
6794 }
6795 ContentElement::Table(table) => {
6796 render_table(out, table);
6797 }
6798 ContentElement::TableBorder(table) => {
6799 render_table_border(out, table);
6800 }
6801 ContentElement::Formula(f) => {
6802 let latex = f.latex.trim();
6803 if !latex.is_empty() {
6804 out.push_str(&format!("$$\n{}\n$$\n\n", latex));
6805 }
6806 }
6807 ContentElement::Caption(c) => {
6808 let text = c.base.value();
6809 let normalized = normalize_common_ocr_text(text.trim());
6810 let trimmed = normalized.trim();
6811 if !trimmed.is_empty() {
6812 out.push_str(&format!("*{}*\n\n", trimmed));
6813 }
6814 }
6815 ContentElement::NumberHeading(nh) => {
6816 let text = nh.base.base.base.value();
6817 let trimmed = text.trim();
6818 if should_skip_heading_text(trimmed) {
6819 return;
6820 }
6821 out.push_str(&format!("# {}\n\n", trimmed));
6822 }
6823 ContentElement::Image(_) => {
6824 out.push_str("\n\n");
6825 }
6826 ContentElement::HeaderFooter(_) => {
6827 }
6829 ContentElement::TextBlock(tb) => {
6830 let text = tb.value();
6831 let trimmed = clean_paragraph_text(&text);
6832 if !trimmed.is_empty() {
6833 out.push_str(&escape_md_line_start(&trimmed));
6834 out.push_str("\n\n");
6835 }
6836 }
6837 ContentElement::TextLine(tl) => {
6838 let text = tl.value();
6839 let normalized = normalize_common_ocr_text(text.trim());
6840 let trimmed = normalized.trim();
6841 if !trimmed.is_empty() {
6842 out.push_str(trimmed);
6843 out.push('\n');
6844 }
6845 }
6846 ContentElement::TextChunk(tc) => {
6847 out.push_str(&tc.value);
6848 }
6849 _ => {}
6850 }
6851}
6852
6853fn escape_md_line_start(text: &str) -> String {
6855 if text.starts_with('>') || text.starts_with('#') {
6856 format!("\\{}", text)
6857 } else {
6858 text.to_string()
6859 }
6860}
6861
6862fn starts_with_caption_prefix(text: &str) -> bool {
6863 let lower = text.trim_start().to_ascii_lowercase();
6864 [
6865 "figure ",
6866 "fig. ",
6867 "table ",
6868 "tab. ",
6869 "chart ",
6870 "graph ",
6871 "image ",
6872 "illustration ",
6873 "diagram ",
6874 "plate ",
6875 "map ",
6876 "exhibit ",
6877 "photo by ",
6878 "photo credit",
6879 "image by ",
6880 "image credit",
6881 "image courtesy",
6882 "photo courtesy",
6883 "credit: ",
6884 "source: ",
6885 ]
6886 .iter()
6887 .any(|prefix| lower.starts_with(prefix))
6888}
6889
6890fn is_structural_caption(text: &str) -> bool {
6891 let lower = text.trim().to_ascii_lowercase();
6892 lower.starts_with("figure ")
6893 || lower.starts_with("table ")
6894 || lower.starts_with("diagram ")
6895 || lower.starts_with("chart ")
6896}
6897
6898fn normalize_chart_like_markdown(markdown: &str) -> String {
6899 let blocks: Vec<&str> = markdown
6900 .split("\n\n")
6901 .map(str::trim)
6902 .filter(|block| !block.is_empty())
6903 .collect();
6904 if blocks.is_empty() {
6905 return markdown.trim().to_string();
6906 }
6907
6908 let mut normalized = Vec::new();
6909 let mut i = 0usize;
6910 while i < blocks.len() {
6911 if let Some(rendered) = trim_large_top_table_plate(&blocks, i) {
6912 normalized.push(rendered);
6913 break;
6914 }
6915
6916 if let Some((rendered, consumed)) = render_header_pair_chart_table(&blocks, i) {
6917 normalized.push(rendered);
6918 i += consumed;
6919 continue;
6920 }
6921
6922 if let Some((rendered, consumed)) = render_chart_block(&blocks, i) {
6923 normalized.push(rendered);
6924 i += consumed;
6925 continue;
6926 }
6927
6928 if let Some((rendered, consumed)) = render_structural_caption_block(&blocks, i) {
6929 normalized.push(rendered);
6930 i += consumed;
6931 continue;
6932 }
6933
6934 if should_drop_artifact_table_block(&blocks, i) {
6935 i += 1;
6936 continue;
6937 }
6938
6939 if !looks_like_footer_banner(blocks[i]) {
6940 normalized.push(blocks[i].to_string());
6941 }
6942 i += 1;
6943 }
6944
6945 normalized.join("\n\n").trim().to_string() + "\n"
6946}
6947
6948fn trim_large_top_table_plate(blocks: &[&str], start: usize) -> Option<String> {
6949 if start != 0 {
6950 return None;
6951 }
6952
6953 let rows = parse_pipe_table_block(blocks.first()?.trim())?;
6954 let body_rows = rows.len().saturating_sub(2);
6955 let max_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
6956 if body_rows < 8 || max_cols < 8 {
6957 return None;
6958 }
6959
6960 let caption = blocks.get(1)?.trim();
6961 if !caption.starts_with("Table ") || caption.split_whitespace().count() < 12 {
6962 return None;
6963 }
6964
6965 let has_following_section = blocks.iter().skip(2).any(|block| {
6966 let trimmed = block.trim();
6967 trimmed.starts_with("# ")
6968 || trimmed.starts_with("## ")
6969 || trimmed.chars().next().is_some_and(|ch| ch.is_ascii_digit())
6970 && trimmed.contains(" Main Results")
6971 });
6972 has_following_section.then_some(blocks[0].trim().to_string())
6973}
6974
6975fn render_header_pair_chart_table(blocks: &[&str], start: usize) -> Option<(String, usize)> {
6976 let caption = blocks.get(start)?.trim();
6977 if !is_structural_caption(caption) {
6978 return None;
6979 }
6980
6981 let rows = parse_pipe_table_block(blocks.get(start + 1)?)?;
6982 if rows.len() != 2 {
6983 return None;
6984 }
6985
6986 let pairs = extract_value_year_pairs_from_cells(&rows[0]);
6987 if pairs.len() < 4 {
6988 return None;
6989 }
6990
6991 let mut source = String::new();
6992 let mut consumed = 2usize;
6993 if let Some(next_block) = blocks.get(start + 2) {
6994 let next = next_block.trim();
6995 if next.to_ascii_lowercase().starts_with("source:") {
6996 source = next.to_string();
6997 consumed += 1;
6998 }
6999 }
7000
7001 let mut out = String::new();
7002 out.push_str(caption);
7004 out.push_str("\n\n");
7005 out.push_str(&format!("| Year | {} |\n", chart_value_header(caption)));
7006 out.push_str("| --- | --- |\n");
7007 for (year, value) in pairs {
7008 out.push_str(&format!("| {} | {} |\n", year, value));
7009 }
7010 out.push('\n');
7011
7012 if !source.is_empty() {
7013 out.push('*');
7014 out.push_str(&escape_md_line_start(&source));
7015 out.push_str("*\n\n");
7016 }
7017
7018 Some((out.trim().to_string(), consumed))
7019}
7020
7021fn render_chart_block(blocks: &[&str], start: usize) -> Option<(String, usize)> {
7022 let (caption, numeric_tokens) = split_chart_caption_and_values(blocks.get(start)?)?;
7023 let mut consumed = 1usize;
7024
7025 let mut source = String::new();
7026 let mut labels = Vec::new();
7027 if let Some(next_block) = blocks.get(start + 1) {
7028 let (candidate_labels, candidate_source) = extract_chart_labels_and_source(next_block);
7029 if !candidate_source.is_empty() || !candidate_labels.is_empty() {
7030 labels = candidate_labels;
7031 source = candidate_source;
7032 consumed += 1;
7033 }
7034 }
7035
7036 while let Some(block) = blocks.get(start + consumed) {
7037 if looks_like_numeric_noise_block(block) {
7038 consumed += 1;
7039 continue;
7040 }
7041 break;
7042 }
7043
7044 let value_tokens = derive_chart_series_values(&numeric_tokens, labels.len());
7045
7046 let mut out = String::new();
7047 out.push_str(caption.trim());
7049 out.push_str("\n\n");
7050
7051 if labels.len() >= 3 && labels.len() == value_tokens.len() {
7052 let label_header = if labels.iter().all(|label| looks_like_yearish_label(label)) {
7053 "Year"
7054 } else {
7055 "Label"
7056 };
7057 let value_header = chart_value_header(&caption);
7058 out.push_str(&format!("| {} | {} |\n", label_header, value_header));
7059 out.push_str("| --- | --- |\n");
7060 for (label, value) in labels.iter().zip(value_tokens.iter()) {
7061 out.push_str(&format!("| {} | {} |\n", label, value));
7062 }
7063 out.push('\n');
7064 }
7065
7066 if !source.is_empty() {
7067 out.push('*');
7068 out.push_str(&escape_md_line_start(&source));
7069 out.push_str("*\n\n");
7070 }
7071
7072 Some((out.trim().to_string(), consumed))
7073}
7074
7075fn render_structural_caption_block(blocks: &[&str], start: usize) -> Option<(String, usize)> {
7076 let block = blocks.get(start)?.trim();
7077 if !is_structural_caption(block) || block.contains('|') {
7078 return None;
7079 }
7080
7081 let mut caption = collapse_inline_whitespace(block);
7082 let mut consumed = 1usize;
7083 if let Some(next_block) = blocks.get(start + 1) {
7084 let next = next_block.trim();
7085 if looks_like_caption_continuation(next) {
7086 caption.push(' ');
7087 caption.push_str(next.trim_end_matches('.'));
7088 consumed += 1;
7089 } else if !looks_like_isolated_caption_context(block, next) {
7090 return None;
7091 }
7092 } else {
7093 return None;
7094 }
7095
7096 Some((format!("{}", caption.trim()), consumed))
7097}
7098
7099fn split_chart_caption_and_values(block: &str) -> Option<(String, Vec<String>)> {
7100 let trimmed = block.trim();
7101 if !is_structural_caption(trimmed) {
7102 return None;
7103 }
7104
7105 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
7106 let first_numeric_idx = tokens.iter().position(|token| is_numberish_token(token))?;
7107 if first_numeric_idx < 3 {
7108 return None;
7109 }
7110
7111 let caption = tokens[..first_numeric_idx].join(" ");
7112 let numeric_tokens: Vec<String> = tokens[first_numeric_idx..]
7113 .iter()
7114 .filter_map(|token| sanitize_numberish_token(token))
7115 .collect();
7116
7117 if numeric_tokens.len() < 4 {
7118 return None;
7119 }
7120
7121 Some((caption, numeric_tokens))
7122}
7123
7124fn parse_pipe_table_block(block: &str) -> Option<Vec<Vec<String>>> {
7125 let lines: Vec<&str> = block
7126 .lines()
7127 .map(str::trim)
7128 .filter(|line| !line.is_empty())
7129 .collect();
7130 if lines.len() < 2 {
7131 return None;
7132 }
7133
7134 let header = split_pipe_row(lines[0])?;
7135 if !is_pipe_separator_row(lines[1], header.len()) {
7136 return None;
7137 }
7138
7139 let mut rows = vec![header];
7140 rows.push(split_pipe_row(lines[1]).unwrap_or_default());
7141 for line in lines.iter().skip(2) {
7142 let row = split_pipe_row(line)?;
7143 rows.push(row);
7144 }
7145 Some(rows)
7146}
7147
7148fn split_pipe_row(line: &str) -> Option<Vec<String>> {
7149 let trimmed = line.trim();
7150 if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
7151 return None;
7152 }
7153
7154 Some(
7155 trimmed[1..trimmed.len() - 1]
7156 .split('|')
7157 .map(|cell| cell.trim().to_string())
7158 .collect(),
7159 )
7160}
7161
7162fn is_pipe_separator_row(line: &str, expected_cols: usize) -> bool {
7163 let Some(cells) = split_pipe_row(line) else {
7164 return false;
7165 };
7166 if cells.len() != expected_cols || expected_cols == 0 {
7167 return false;
7168 }
7169
7170 cells.iter().all(|cell| {
7171 let stripped = cell.trim_matches(':').trim();
7172 !stripped.is_empty() && stripped.chars().all(|ch| ch == '-')
7173 })
7174}
7175
7176fn extract_value_year_pairs_from_cells(cells: &[String]) -> Vec<(String, String)> {
7177 let mut pairs = Vec::new();
7178 for cell in cells {
7179 let tokens: Vec<&str> = cell.split_whitespace().collect();
7180 if tokens.len() != 2 {
7181 continue;
7182 }
7183
7184 if looks_like_year_token(tokens[0]) && is_numberish_token(tokens[1]) {
7185 if let Some(value) = sanitize_numberish_token(tokens[1]) {
7186 pairs.push((tokens[0].to_string(), value));
7187 }
7188 continue;
7189 }
7190
7191 if is_numberish_token(tokens[0]) && looks_like_year_token(tokens[1]) {
7192 if let Some(value) = sanitize_numberish_token(tokens[0]) {
7193 pairs.push((tokens[1].to_string(), value));
7194 }
7195 }
7196 }
7197
7198 pairs.sort_by(|left, right| left.0.cmp(&right.0));
7199 pairs
7200}
7201
7202fn should_drop_artifact_table_block(blocks: &[&str], start: usize) -> bool {
7203 let Some(rows) = parse_pipe_table_block(blocks[start]) else {
7204 return false;
7205 };
7206
7207 let prev = start
7208 .checked_sub(1)
7209 .and_then(|idx| blocks.get(idx))
7210 .map(|block| block.trim())
7211 .unwrap_or("");
7212 let next = blocks
7213 .get(start + 1)
7214 .map(|block| block.trim())
7215 .unwrap_or("");
7216
7217 if rows.len() == 2 && rows.first().is_some_and(|row| row.len() == 1) {
7218 let header = rows[0][0].trim();
7219 if looks_like_url_fragment(header) {
7220 return true;
7221 }
7222 if looks_like_numeric_axis_blob(header) && !previous_block_announces_table(prev) {
7223 return true;
7224 }
7225 }
7226
7227 let stats = pipe_table_stats(&rows);
7228 stats.fill_ratio < 0.5
7229 && stats.long_cell_count == 0
7230 && !is_structural_caption(prev)
7231 && (looks_like_citation_block(next) || is_structural_caption(next))
7232}
7233
7234fn previous_block_announces_table(block: &str) -> bool {
7235 let lower = block.trim().to_ascii_lowercase();
7236 lower.ends_with("as follows:")
7237 || lower.ends_with("following details:")
7238 || lower.ends_with("following detail:")
7239 || lower.contains("the following details")
7240}
7241
7242fn looks_like_url_fragment(text: &str) -> bool {
7243 let trimmed = text.trim();
7244 (!trimmed.is_empty() && (trimmed.contains("http") || trimmed.contains("/status/")))
7245 || (trimmed.contains('/') && !trimmed.contains(' '))
7246}
7247
7248fn looks_like_numeric_axis_blob(text: &str) -> bool {
7249 let numeric_values: Vec<i64> = text
7250 .split_whitespace()
7251 .filter_map(parse_integer_token)
7252 .collect();
7253 numeric_values.len() >= 8
7254 && !detect_axis_progression(&numeric_values).is_empty()
7255 && text.chars().any(char::is_alphabetic)
7256}
7257
7258fn looks_like_citation_block(block: &str) -> bool {
7259 let trimmed = block.trim();
7260 trimmed.starts_with('(') && trimmed.ends_with(')') && trimmed.split_whitespace().count() <= 8
7261}
7262
7263struct PipeTableStats {
7264 fill_ratio: f64,
7265 long_cell_count: usize,
7266}
7267
7268fn pipe_table_stats(rows: &[Vec<String>]) -> PipeTableStats {
7269 let cols = rows.iter().map(Vec::len).max().unwrap_or(0).max(1);
7270 let body = rows.len().saturating_sub(2);
7271 let mut nonempty = 0usize;
7272 let mut long_cell_count = 0usize;
7273
7274 for row in rows.iter().skip(2) {
7275 for cell in row {
7276 if !cell.trim().is_empty() {
7277 nonempty += 1;
7278 if cell.split_whitespace().count() >= 3 {
7279 long_cell_count += 1;
7280 }
7281 }
7282 }
7283 }
7284
7285 let fill_ratio = if body == 0 {
7286 0.0
7287 } else {
7288 nonempty as f64 / (body * cols) as f64
7289 };
7290
7291 PipeTableStats {
7292 fill_ratio,
7293 long_cell_count,
7294 }
7295}
7296
7297fn extract_chart_labels_and_source(block: &str) -> (Vec<String>, String) {
7298 let trimmed = block.trim();
7299 let lower = trimmed.to_ascii_lowercase();
7300 let source_idx = lower.find("source:");
7301
7302 let label_region = source_idx.map_or(trimmed, |idx| trimmed[..idx].trim());
7303 let source = source_idx
7304 .map(|idx| trimmed[idx..].trim().to_string())
7305 .unwrap_or_default();
7306
7307 let labels = parse_chart_labels(label_region);
7308 (labels, source)
7309}
7310
7311fn parse_chart_labels(text: &str) -> Vec<String> {
7312 let tokens: Vec<&str> = text.split_whitespace().collect();
7313 let mut labels = Vec::new();
7314 let mut i = 0usize;
7315 while i < tokens.len() {
7316 let token = tokens[i].trim_matches(|c: char| c == ',' || c == ';');
7317 if looks_like_year_token(token) {
7318 let mut label = token.to_string();
7319 if let Some(next) = tokens.get(i + 1) {
7320 let next_trimmed = next.trim_matches(|c: char| c == ',' || c == ';');
7321 if next_trimmed.starts_with('(') && next_trimmed.ends_with(')') {
7322 label.push(' ');
7323 label.push_str(next_trimmed);
7324 i += 1;
7325 }
7326 }
7327 labels.push(label);
7328 } else if looks_like_category_label(token) {
7329 labels.push(token.to_string());
7330 }
7331 i += 1;
7332 }
7333 labels
7334}
7335
7336fn derive_chart_series_values(tokens: &[String], expected_count: usize) -> Vec<String> {
7337 if expected_count == 0 {
7338 return Vec::new();
7339 }
7340
7341 if tokens.len() == expected_count {
7342 return tokens.to_vec();
7343 }
7344
7345 let numeric_values: Vec<i64> = tokens
7346 .iter()
7347 .filter_map(|token| parse_integer_token(token))
7348 .collect();
7349 if numeric_values.len() != tokens.len() {
7350 return Vec::new();
7351 }
7352
7353 let axis_series = detect_axis_progression(&numeric_values);
7354 if axis_series.is_empty() {
7355 return Vec::new();
7356 }
7357
7358 let mut remaining = Vec::new();
7359 let mut removable = axis_series;
7360 for token in tokens {
7361 let Some(value) = parse_integer_token(token) else {
7362 continue;
7363 };
7364 if let Some(pos) = removable.iter().position(|candidate| *candidate == value) {
7365 removable.remove(pos);
7366 } else {
7367 remaining.push(token.clone());
7368 }
7369 }
7370
7371 if remaining.len() == expected_count {
7372 remaining
7373 } else {
7374 Vec::new()
7375 }
7376}
7377
7378fn detect_axis_progression(values: &[i64]) -> Vec<i64> {
7379 if values.len() < 6 {
7380 return Vec::new();
7381 }
7382
7383 let mut sorted = values.to_vec();
7384 sorted.sort_unstable();
7385 sorted.dedup();
7386 if sorted.len() < 6 {
7387 return Vec::new();
7388 }
7389
7390 let mut best = Vec::new();
7391 for window in sorted.windows(2) {
7392 let step = window[1] - window[0];
7393 if step <= 0 {
7394 continue;
7395 }
7396
7397 let mut series = vec![window[0]];
7398 let mut current = window[0];
7399 loop {
7400 let next = current + step;
7401 if sorted.binary_search(&next).is_ok() {
7402 series.push(next);
7403 current = next;
7404 } else {
7405 break;
7406 }
7407 }
7408
7409 if series.len() > best.len() {
7410 best = series;
7411 }
7412 }
7413
7414 if best.len() >= 6 {
7415 best
7416 } else {
7417 Vec::new()
7418 }
7419}
7420
7421fn chart_value_header(caption: &str) -> String {
7422 let trimmed = caption.trim();
7423 let title = strip_structural_caption_prefix(trimmed);
7424
7425 let mut base = title.to_string();
7426 if let Some(idx) = base.rfind(" in ") {
7427 let tail = base[idx + 4..].trim();
7428 if tail.split_whitespace().count() <= 2
7429 && tail.chars().next().is_some_and(char::is_uppercase)
7430 {
7431 base.truncate(idx);
7432 }
7433 }
7434
7435 if let Some(start) = title.rfind('(') {
7436 if title.ends_with(')') {
7437 let unit = title[start + 1..title.len() - 1].trim();
7438 if let Some(idx) = base.rfind('(') {
7439 base.truncate(idx);
7440 }
7441 let normalized_unit = unit.strip_prefix("in ").unwrap_or(unit).trim();
7442 return format!("{} ({})", base.trim(), normalized_unit);
7443 }
7444 }
7445
7446 let trimmed = base.trim();
7447 if trimmed.is_empty() {
7448 "Value".to_string()
7449 } else {
7450 trimmed.to_string()
7451 }
7452}
7453
7454fn strip_structural_caption_prefix(text: &str) -> &str {
7455 let trimmed = text.trim();
7456 let mut parts = trimmed.splitn(3, ' ');
7457 let Some(first) = parts.next() else {
7458 return trimmed;
7459 };
7460 let Some(second) = parts.next() else {
7461 return trimmed;
7462 };
7463 let Some(rest) = parts.next() else {
7464 return trimmed;
7465 };
7466
7467 let first_lower = first.to_ascii_lowercase();
7468 if matches!(
7469 first_lower.as_str(),
7470 "figure" | "table" | "diagram" | "chart"
7471 ) && second
7472 .chars()
7473 .all(|ch| ch.is_ascii_digit() || matches!(ch, '.' | ':'))
7474 {
7475 rest.trim()
7476 } else {
7477 trimmed
7478 }
7479}
7480
7481fn looks_like_footer_banner(block: &str) -> bool {
7482 let trimmed = block.trim();
7483 if trimmed.contains('\n') || trimmed.len() < 8 {
7484 return false;
7485 }
7486
7487 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
7488 if !(2..=6).contains(&tokens.len()) {
7489 return false;
7490 }
7491
7492 let Some(last) = tokens.last() else {
7493 return false;
7494 };
7495 if !last.chars().all(|ch| ch.is_ascii_digit()) {
7496 return false;
7497 }
7498
7499 tokens[..tokens.len() - 1].iter().all(|token| {
7500 matches!(
7501 token.to_ascii_lowercase().as_str(),
7502 "of" | "and" | "the" | "for" | "in" | "on"
7503 ) || token.chars().next().is_some_and(char::is_uppercase)
7504 })
7505}
7506
7507fn looks_like_caption_continuation(block: &str) -> bool {
7508 let trimmed = block.trim();
7509 !trimmed.is_empty()
7510 && trimmed.split_whitespace().count() <= 8
7511 && trimmed.chars().next().is_some_and(char::is_uppercase)
7512 && !trimmed.contains(':')
7513}
7514
7515fn collapse_inline_whitespace(text: &str) -> String {
7516 text.split_whitespace().collect::<Vec<_>>().join(" ")
7517}
7518
7519fn drop_isolated_noise_lines(markdown: &str) -> String {
7520 let lines: Vec<&str> = markdown.lines().collect();
7521 let mut kept = Vec::with_capacity(lines.len());
7522
7523 for (idx, line) in lines.iter().enumerate() {
7524 if should_drop_isolated_noise_line(&lines, idx) {
7525 continue;
7526 }
7527 kept.push(*line);
7528 }
7529
7530 let mut result = kept.join("\n");
7531 if markdown.ends_with('\n') {
7532 result.push('\n');
7533 }
7534 result
7535}
7536
7537fn should_drop_isolated_noise_line(lines: &[&str], idx: usize) -> bool {
7538 let trimmed = lines[idx].trim();
7539 if trimmed.len() != 1 {
7540 return false;
7541 }
7542
7543 let ch = trimmed.chars().next().unwrap_or_default();
7544 if !(ch.is_ascii_lowercase() || ch.is_ascii_digit()) {
7545 return false;
7546 }
7547
7548 let prev = previous_nonempty_line(lines, idx);
7549 let next = next_nonempty_line(lines, idx);
7550 let (Some(prev), Some(next)) = (prev, next) else {
7551 return false;
7552 };
7553
7554 is_substantive_markdown_line(prev) && is_substantive_markdown_line(next)
7555}
7556
7557fn previous_nonempty_line<'a>(lines: &'a [&'a str], idx: usize) -> Option<&'a str> {
7558 lines[..idx]
7559 .iter()
7560 .rev()
7561 .find(|line| !line.trim().is_empty())
7562 .copied()
7563}
7564
7565fn next_nonempty_line<'a>(lines: &'a [&'a str], idx: usize) -> Option<&'a str> {
7566 lines[idx + 1..]
7567 .iter()
7568 .find(|line| !line.trim().is_empty())
7569 .copied()
7570}
7571
7572fn is_substantive_markdown_line(line: &str) -> bool {
7573 let trimmed = line.trim();
7574 if trimmed.is_empty() {
7575 return false;
7576 }
7577
7578 if trimmed.starts_with('|') || trimmed.starts_with("- ") || trimmed.starts_with('#') {
7579 return true;
7580 }
7581
7582 trimmed.split_whitespace().count() >= 2
7583}
7584
7585fn normalize_common_ocr_text(text: &str) -> String {
7586 if text.is_empty() {
7587 return String::new();
7588 }
7589
7590 let mut normalized = text
7591 .replace("ߤL", "μL")
7592 .replace(" oC", "°C")
7593 .replace("37 C", "37°C")
7594 .replace("-20 oC", "-20°C")
7595 .replace("1- 20-μL", "1-20-μL")
7596 .replace("1- 20 μL", "1-20 μL")
7597 .replace("1- 2 0 μL", "1-20 μL")
7598 .replace("1- 2 0 μL", "1-20 μL");
7599
7600 normalized = normalize_degree_spacing(&normalized);
7601 collapse_inline_whitespace(&normalized)
7602}
7603
7604fn normalize_degree_spacing(text: &str) -> String {
7605 let chars: Vec<char> = text.chars().collect();
7606 let mut out = String::with_capacity(text.len());
7607 let mut i = 0usize;
7608 while i < chars.len() {
7609 let ch = chars[i];
7610 if ch == ' '
7611 && i > 0
7612 && i + 2 < chars.len()
7613 && chars[i - 1].is_ascii_digit()
7614 && matches!(chars[i + 1], 'C' | 'F')
7615 && !chars[i + 2].is_ascii_alphabetic()
7616 {
7617 out.push('°');
7618 out.push(chars[i + 1]);
7619 i += 2;
7620 continue;
7621 }
7622 out.push(ch);
7623 i += 1;
7624 }
7625 out
7626}
7627
7628fn normalize_list_text(text: &str) -> String {
7629 let normalized = normalize_common_ocr_text(text);
7630 let trimmed = normalized
7631 .trim_start_matches(|ch: char| is_bullet_like(ch))
7632 .trim();
7633 trimmed.to_string()
7634}
7635
7636fn push_rendered_list_item(out: &mut String, item: &str) {
7637 if starts_with_enumerated_marker(item) {
7638 out.push_str(item);
7639 out.push('\n');
7640 } else {
7641 out.push_str(&format!("- {}\n", item));
7642 }
7643}
7644
7645fn should_merge_list_continuation(previous: &str, current: &str) -> bool {
7646 let trimmed = current.trim();
7647 if trimmed.is_empty()
7648 || looks_like_stray_list_page_number(trimmed)
7649 || is_list_section_heading(trimmed)
7650 || looks_like_numbered_section(trimmed)
7651 || starts_with_enumerated_marker(trimmed)
7652 {
7653 return false;
7654 }
7655
7656 if previous.ends_with('-')
7657 && previous
7658 .chars()
7659 .rev()
7660 .nth(1)
7661 .is_some_and(|c| c.is_alphabetic())
7662 && trimmed.chars().next().is_some_and(char::is_lowercase)
7663 {
7664 return true;
7665 }
7666
7667 trimmed
7668 .chars()
7669 .next()
7670 .is_some_and(|ch| ch.is_ascii_lowercase() || matches!(ch, ',' | ';' | ')' | ']' | '%'))
7671}
7672
7673fn is_pure_bullet_marker(text: &str) -> bool {
7674 let trimmed = text.trim();
7675 !trimmed.is_empty() && trimmed.chars().all(is_bullet_like)
7676}
7677
7678fn looks_like_stray_list_page_number(text: &str) -> bool {
7679 let trimmed = text.trim();
7680 (1..=4).contains(&trimmed.len()) && trimmed.chars().all(|ch| ch.is_ascii_digit())
7681}
7682
7683fn is_bullet_like(ch: char) -> bool {
7684 matches!(
7685 ch,
7686 '•' | '◦'
7687 | '▪'
7688 | '▸'
7689 | '▹'
7690 | '►'
7691 | '▻'
7692 | '●'
7693 | '○'
7694 | '■'
7695 | '□'
7696 | '◆'
7697 | '◇'
7698 | '-'
7699 )
7700}
7701
7702fn looks_like_isolated_caption_context(caption: &str, next_block: &str) -> bool {
7703 let next = next_block.trim();
7704 if next.is_empty() {
7705 return false;
7706 }
7707
7708 let next_lower = next.to_ascii_lowercase();
7709 if next_lower.starts_with("source:")
7710 || next_lower.starts_with("note:")
7711 || next_lower.starts_with("*source:")
7712 || next_lower.starts_with("*note:")
7713 {
7714 return true;
7715 }
7716
7717 caption.split_whitespace().count() <= 14
7718 && next.split_whitespace().count() <= 45
7719 && (next.contains(':') || next.contains('='))
7720}
7721
7722fn looks_like_numeric_noise_block(block: &str) -> bool {
7723 let trimmed = block.trim();
7724 !trimmed.is_empty()
7725 && trimmed.split_whitespace().all(|token| {
7726 sanitize_numberish_token(token)
7727 .as_deref()
7728 .is_some_and(|sanitized| sanitized.chars().all(|ch| ch.is_ascii_digit()))
7729 })
7730}
7731
7732fn looks_like_yearish_label(label: &str) -> bool {
7733 label.chars().next().is_some_and(|ch| ch.is_ascii_digit())
7734}
7735
7736fn looks_like_year_token(token: &str) -> bool {
7737 token.len() == 4 && token.chars().all(|ch| ch.is_ascii_digit())
7738}
7739
7740fn looks_like_category_label(token: &str) -> bool {
7741 token
7742 .chars()
7743 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '/' | '%'))
7744 && token.chars().any(|ch| ch.is_ascii_alphabetic())
7745}
7746
7747fn is_numberish_token(token: &str) -> bool {
7748 sanitize_numberish_token(token).is_some()
7749}
7750
7751fn sanitize_numberish_token(token: &str) -> Option<String> {
7752 let trimmed = token.trim_matches(|c: char| matches!(c, ',' | ';' | ':' | '.'));
7753 if trimmed.is_empty() {
7754 return None;
7755 }
7756
7757 let candidate = trimmed.trim_end_matches('%').replace(',', "");
7758 if candidate.chars().all(|ch| ch.is_ascii_digit()) {
7759 Some(trimmed.trim_end_matches([',', ';', ':']).to_string())
7760 } else {
7761 None
7762 }
7763}
7764
7765fn parse_integer_token(token: &str) -> Option<i64> {
7766 sanitize_numberish_token(token)?
7767 .replace(',', "")
7768 .parse::<i64>()
7769 .ok()
7770}
7771
7772fn starts_with_uppercase_word(text: &str) -> bool {
7773 for ch in text.trim_start().chars() {
7774 if ch.is_alphabetic() {
7775 return ch.is_uppercase();
7776 }
7777 if !matches!(ch, '"' | '\'' | '(' | '[') {
7778 break;
7779 }
7780 }
7781 false
7782}
7783
7784fn clean_paragraph_text(text: &str) -> String {
7787 let trimmed = text.trim();
7788 if trimmed.is_empty() {
7789 return String::new();
7790 }
7791 let mut result = String::with_capacity(trimmed.len());
7793 let mut prev_space = false;
7794 for ch in trimmed.chars() {
7795 if ch == ' ' || ch == '\t' {
7796 if !prev_space {
7797 result.push(' ');
7798 prev_space = true;
7799 }
7800 } else {
7801 result.push(ch);
7802 prev_space = false;
7803 }
7804 }
7805 normalize_common_ocr_text(&result)
7806}
7807
7808fn next_mergeable_paragraph_text(element: Option<&ContentElement>) -> Option<String> {
7809 match element {
7810 Some(ContentElement::Paragraph(p)) => {
7811 let text = clean_paragraph_text(&p.base.value());
7812 let trimmed = text.trim();
7813 if trimmed.is_empty()
7814 || should_render_element_as_heading(element.unwrap(), trimmed, None)
7815 {
7816 None
7817 } else {
7818 Some(trimmed.to_string())
7819 }
7820 }
7821 Some(ContentElement::TextBlock(tb)) => {
7822 let text = clean_paragraph_text(&tb.value());
7823 let trimmed = text.trim();
7824 if trimmed.is_empty()
7825 || should_render_element_as_heading(element.unwrap(), trimmed, None)
7826 {
7827 None
7828 } else {
7829 Some(trimmed.to_string())
7830 }
7831 }
7832 Some(ContentElement::TextLine(tl)) => {
7833 let text = clean_paragraph_text(&tl.value());
7834 let trimmed = text.trim();
7835 if trimmed.is_empty()
7836 || should_render_element_as_heading(element.unwrap(), trimmed, None)
7837 {
7838 None
7839 } else {
7840 Some(trimmed.to_string())
7841 }
7842 }
7843 _ => None,
7844 }
7845}
7846
7847fn should_render_paragraph_as_heading(
7848 doc: &PdfDocument,
7849 idx: usize,
7850 text: &str,
7851 next: Option<&ContentElement>,
7852) -> bool {
7853 if looks_like_top_margin_running_header(doc, idx, text) {
7854 return false;
7855 }
7856 if looks_like_hyphenated_table_title_continuation(doc, idx, text, next) {
7857 return true;
7858 }
7859 if should_render_element_as_heading(&doc.kids[idx], text, next) {
7860 return true;
7861 }
7862
7863 let body_font_size = compute_body_font_size(doc);
7866 if is_too_small_for_heading(&doc.kids, idx, body_font_size) {
7867 return false;
7868 }
7869
7870 if !doc_has_explicit_headings(doc) {
7872 if should_rescue_as_heading(doc, idx, text) {
7873 return true;
7874 }
7875 if should_rescue_allcaps_heading(doc, idx, text) {
7879 return true;
7880 }
7881 if should_rescue_numbered_heading(doc, idx, text) {
7882 return true;
7883 }
7884 return false;
7885 }
7886 if heading_density(doc) < 0.10 {
7889 if should_rescue_allcaps_heading(doc, idx, text) {
7890 return true;
7891 }
7892 if should_rescue_numbered_heading(doc, idx, text) {
7896 return true;
7897 }
7898 if body_font_size > 0.0 {
7903 if let ContentElement::Paragraph(p) = &doc.kids[idx] {
7904 if let Some(fs) = p.base.font_size {
7905 if fs >= 1.15 * body_font_size
7906 && is_heading_rescue_candidate(doc, idx, text)
7907 && has_substantive_follow_up(doc, idx, text.split_whitespace().count(), 4)
7908 {
7909 return true;
7910 }
7911 }
7912 }
7913 }
7914 }
7915 false
7916}
7917
7918fn doc_has_explicit_headings(doc: &PdfDocument) -> bool {
7920 doc.kids.iter().any(|e| {
7921 matches!(
7922 e,
7923 ContentElement::Heading(_) | ContentElement::NumberHeading(_)
7924 )
7925 })
7926}
7927
7928fn compute_body_font_size(doc: &PdfDocument) -> f64 {
7933 let mut font_sizes: Vec<f64> = doc
7934 .kids
7935 .iter()
7936 .filter_map(|e| {
7937 if let ContentElement::Paragraph(p) = e {
7938 let word_count = p.base.value().split_whitespace().count();
7939 if word_count > 10 {
7940 p.base.font_size
7941 } else {
7942 None
7943 }
7944 } else {
7945 None
7946 }
7947 })
7948 .collect();
7949 if font_sizes.is_empty() {
7950 return 0.0;
7951 }
7952 font_sizes.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
7953 font_sizes[font_sizes.len() / 2]
7954}
7955
7956fn is_too_small_for_heading(doc_kids: &[ContentElement], idx: usize, body_font_size: f64) -> bool {
7961 if body_font_size <= 0.0 {
7962 return false;
7963 }
7964 if let ContentElement::Paragraph(p) = &doc_kids[idx] {
7965 if let Some(fs) = p.base.font_size {
7966 return fs < 0.95 * body_font_size;
7967 }
7968 }
7969 false
7970}
7971
7972fn heading_density(doc: &PdfDocument) -> f64 {
7974 let total = doc.kids.len();
7975 if total == 0 {
7976 return 0.0;
7977 }
7978 let heading_count = doc
7979 .kids
7980 .iter()
7981 .filter(|e| {
7982 matches!(
7983 e,
7984 ContentElement::Heading(_) | ContentElement::NumberHeading(_)
7985 )
7986 })
7987 .count();
7988 heading_count as f64 / total as f64
7989}
7990
7991fn should_rescue_as_heading(doc: &PdfDocument, idx: usize, text: &str) -> bool {
7994 is_heading_rescue_candidate(doc, idx, text)
7995 && has_substantive_follow_up(doc, idx, text.split_whitespace().count(), 4)
7996}
7997
7998fn is_heading_rescue_candidate(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8002 let trimmed = text.trim();
8003 if trimmed.is_empty() {
8004 return false;
8005 }
8006
8007 let has_alpha = trimmed.chars().any(char::is_alphabetic);
8008
8009 if !has_alpha || trimmed.ends_with(['.', '!', '?', ';', ',']) {
8011 return false;
8012 }
8013
8014 if should_demote_math_heading(trimmed) || should_demote_percentage_heading(trimmed) {
8016 return false;
8017 }
8018
8019 if trimmed.starts_with('(') && trimmed.ends_with(')') {
8021 return false;
8022 }
8023
8024 if starts_with_caption_prefix(trimmed)
8026 || looks_like_chart_label_heading(&doc.kids[idx], trimmed)
8027 {
8028 return false;
8029 }
8030
8031 let word_count = trimmed.split_whitespace().count();
8033 if word_count > 6 || trimmed.len() > 60 {
8034 return false;
8035 }
8036
8037 if trimmed
8039 .chars()
8040 .all(|c| c.is_ascii_digit() || c == '.' || c == ' ')
8041 {
8042 return false;
8043 }
8044
8045 if let Some(first_alpha) = trimmed.chars().find(|c| c.is_alphabetic()) {
8047 if first_alpha.is_lowercase() {
8048 return false;
8049 }
8050 }
8051
8052 true
8053}
8054
8055fn has_substantive_follow_up(
8059 doc: &PdfDocument,
8060 idx: usize,
8061 word_count: usize,
8062 max_lookahead: usize,
8063) -> bool {
8064 for offset in 1..=max_lookahead {
8065 let lookahead_idx = idx + offset;
8066 if lookahead_idx >= doc.kids.len() {
8067 break;
8068 }
8069 let look_elem = &doc.kids[lookahead_idx];
8070 match look_elem {
8071 ContentElement::Paragraph(p) => {
8072 let next_text = p.base.value();
8073 let nw = next_text.split_whitespace().count();
8074 if nw >= word_count * 3 || nw > 15 {
8075 return true;
8076 }
8077 }
8078 ContentElement::TextBlock(tb) => {
8079 let next_text = tb.value();
8080 let nw = next_text.split_whitespace().count();
8081 if nw >= word_count * 3 || nw > 15 {
8082 return true;
8083 }
8084 }
8085 ContentElement::TextLine(tl) => {
8086 let next_text = tl.value();
8087 let nw = next_text.split_whitespace().count();
8088 if nw >= word_count * 3 || nw > 15 {
8089 return true;
8090 }
8091 }
8092 ContentElement::List(_)
8093 | ContentElement::Table(_)
8094 | ContentElement::TableBorder(_)
8095 | ContentElement::Image(_)
8096 | ContentElement::Figure(_) => {
8097 return true;
8098 }
8099 _ => continue,
8100 }
8101 }
8102
8103 false
8104}
8105
8106fn should_rescue_numbered_heading(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8109 let trimmed = text.trim();
8110 if trimmed.is_empty() || trimmed.len() > 100 {
8111 return false;
8112 }
8113
8114 if !looks_like_numbered_section(trimmed) {
8117 return false;
8118 }
8119
8120 if trimmed.ends_with(['!', '?', ';', ',']) {
8124 return false;
8125 }
8126 if trimmed.ends_with('.') && !looks_like_keyword_numbered_section(trimmed) {
8127 return false;
8128 }
8129 if should_demote_math_heading(trimmed) || should_demote_percentage_heading(trimmed) {
8131 return false;
8132 }
8133
8134 for offset in 1..=3 {
8136 let lookahead_idx = idx + offset;
8137 if lookahead_idx >= doc.kids.len() {
8138 break;
8139 }
8140 match &doc.kids[lookahead_idx] {
8141 ContentElement::Paragraph(p) => {
8142 let nw = p.base.value().split_whitespace().count();
8143 if nw > 10 {
8144 return true;
8145 }
8146 }
8147 ContentElement::TextBlock(tb) => {
8148 let nw = tb.value().split_whitespace().count();
8149 if nw > 10 {
8150 return true;
8151 }
8152 }
8153 ContentElement::TextLine(tl) => {
8154 let nw = tl.value().split_whitespace().count();
8155 if nw > 10 {
8156 return true;
8157 }
8158 }
8159 ContentElement::List(_)
8160 | ContentElement::Table(_)
8161 | ContentElement::TableBorder(_)
8162 | ContentElement::Image(_)
8163 | ContentElement::Figure(_) => {
8164 return true;
8165 }
8166 _ => continue,
8167 }
8168 }
8169
8170 false
8171}
8172
8173fn looks_like_numbered_section(text: &str) -> bool {
8176 let bytes = text.as_bytes();
8177 if bytes.is_empty() {
8178 return false;
8179 }
8180
8181 let mut idx = 0;
8183 if bytes[0].is_ascii_digit() {
8184 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
8185 idx += 1;
8186 }
8187 if idx >= bytes.len() {
8188 return false;
8189 }
8190 while idx < bytes.len() && bytes[idx] == b'.' {
8192 idx += 1;
8193 let start = idx;
8194 while idx < bytes.len() && bytes[idx].is_ascii_digit() {
8195 idx += 1;
8196 }
8197 if idx == start {
8198 break;
8200 }
8201 }
8202 if idx >= bytes.len() {
8204 return false;
8205 }
8206 if bytes[idx] == b' ' || bytes[idx] == b'\t' {
8208 idx += 1;
8209 if idx < bytes.len() && bytes[idx] == b'-' {
8211 idx += 1;
8212 if idx < bytes.len() && bytes[idx] == b' ' {
8213 idx += 1;
8214 }
8215 }
8216 } else if bytes[idx] == b'-' {
8217 idx += 1;
8218 if idx < bytes.len() && bytes[idx] == b' ' {
8219 idx += 1;
8220 }
8221 } else {
8222 return false;
8223 }
8224 let rest = &text[idx..].trim();
8226 if rest.is_empty() {
8227 return false;
8228 }
8229 if let Some(c) = rest.chars().find(|c| c.is_alphabetic()) {
8231 return c.is_uppercase();
8232 }
8233 return false;
8234 }
8235
8236 if looks_like_keyword_numbered_section(text) {
8238 return true;
8239 }
8240
8241 false
8242}
8243
8244const SECTION_KEYWORDS: &[&str] = &[
8246 "activity",
8247 "appendix",
8248 "case",
8249 "chapter",
8250 "exercise",
8251 "experiment",
8252 "lab",
8253 "lesson",
8254 "module",
8255 "part",
8256 "phase",
8257 "problem",
8258 "question",
8259 "section",
8260 "stage",
8261 "step",
8262 "task",
8263 "topic",
8264 "unit",
8265];
8266
8267fn looks_like_keyword_numbered_section(text: &str) -> bool {
8269 let trimmed = text.trim();
8270 let space_pos = match trimmed.find(' ') {
8272 Some(p) => p,
8273 None => return false,
8274 };
8275 let keyword = &trimmed[..space_pos];
8276 if !SECTION_KEYWORDS
8277 .iter()
8278 .any(|k| keyword.eq_ignore_ascii_case(k))
8279 {
8280 return false;
8281 }
8282 let rest = trimmed[space_pos + 1..].trim_start();
8284 if rest.is_empty() {
8285 return false;
8286 }
8287 let rest = rest.strip_prefix('#').unwrap_or(rest);
8288 let first_char = rest.chars().next().unwrap_or(' ');
8290 if !first_char.is_ascii_digit() && !matches!(first_char, 'I' | 'V' | 'X' | 'L') {
8291 return false;
8292 }
8293 true
8294}
8295
8296fn should_rescue_allcaps_heading(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8299 let trimmed = text.trim();
8300 if trimmed.is_empty() {
8301 return false;
8302 }
8303
8304 let word_count = trimmed.split_whitespace().count();
8305
8306 if word_count > 8 || trimmed.len() > 80 {
8308 return false;
8309 }
8310
8311 let alpha_chars: Vec<char> = trimmed.chars().filter(|c| c.is_alphabetic()).collect();
8313 if alpha_chars.len() < 2 || !alpha_chars.iter().all(|c| c.is_uppercase()) {
8314 return false;
8315 }
8316
8317 if trimmed.ends_with(['.', ';', ',']) {
8319 return false;
8320 }
8321
8322 if should_demote_math_heading(trimmed) || should_demote_percentage_heading(trimmed) {
8324 return false;
8325 }
8326
8327 if starts_with_caption_prefix(trimmed) {
8329 return false;
8330 }
8331
8332 if trimmed
8334 .chars()
8335 .all(|c| c.is_ascii_digit() || c == '.' || c == ' ')
8336 {
8337 return false;
8338 }
8339
8340 for offset in 1..=4 {
8343 let lookahead_idx = idx + offset;
8344 if lookahead_idx >= doc.kids.len() {
8345 break;
8346 }
8347 let look_elem = &doc.kids[lookahead_idx];
8348 match look_elem {
8349 ContentElement::Paragraph(p) => {
8350 let nw = p.base.value().split_whitespace().count();
8351 if nw > 6 {
8352 return true;
8353 }
8354 }
8355 ContentElement::TextBlock(tb) => {
8356 let nw = tb.value().split_whitespace().count();
8357 if nw > 6 {
8358 return true;
8359 }
8360 }
8361 ContentElement::TextLine(tl) => {
8362 let nw = tl.value().split_whitespace().count();
8363 if nw > 6 {
8364 return true;
8365 }
8366 }
8367 ContentElement::List(_)
8368 | ContentElement::Table(_)
8369 | ContentElement::TableBorder(_)
8370 | ContentElement::Image(_)
8371 | ContentElement::Figure(_) => {
8372 return true;
8373 }
8374 _ => continue,
8375 }
8376 }
8377
8378 false
8379}
8380
8381fn should_render_element_as_heading(
8382 element: &ContentElement,
8383 text: &str,
8384 next: Option<&ContentElement>,
8385) -> bool {
8386 let trimmed = text.trim();
8387 if trimmed.is_empty() {
8388 return false;
8389 }
8390
8391 let lower = trimmed.to_ascii_lowercase();
8392 if matches!(lower.as_str(), "contents" | "table of contents")
8393 && trimmed.starts_with(|c: char| c.is_uppercase())
8394 {
8395 return true;
8396 }
8397
8398 let word_count = trimmed.split_whitespace().count();
8399 let has_alpha = trimmed.chars().any(char::is_alphabetic);
8400 let title_like = has_alpha
8401 && word_count <= 4
8402 && trimmed.len() <= 40
8403 && !trimmed.ends_with(['.', '!', '?', ';', ':']);
8404
8405 let is_attribution = {
8409 let lower = trimmed.to_ascii_lowercase();
8410 lower.starts_with("source:")
8411 || lower.starts_with("credit:")
8412 || lower.starts_with("photo by ")
8413 || lower.starts_with("photo credit")
8414 || lower.starts_with("image by ")
8415 || lower.starts_with("image credit")
8416 };
8417
8418 title_like
8419 && matches!(next, Some(ContentElement::List(_)))
8420 && !looks_like_chart_label_heading(element, trimmed)
8421 && !is_attribution
8422}
8423
8424fn looks_like_hyphenated_table_title_continuation(
8425 doc: &PdfDocument,
8426 idx: usize,
8427 text: &str,
8428 next: Option<&ContentElement>,
8429) -> bool {
8430 if !matches!(
8431 next,
8432 Some(ContentElement::Table(_)) | Some(ContentElement::TableBorder(_))
8433 ) {
8434 return false;
8435 }
8436
8437 let trimmed = text.trim();
8438 if trimmed.is_empty()
8439 || starts_with_caption_prefix(trimmed)
8440 || looks_like_numbered_section(trimmed)
8441 || looks_like_keyword_numbered_section(trimmed)
8442 || !trimmed.ends_with(':')
8443 {
8444 return false;
8445 }
8446
8447 let word_count = trimmed.split_whitespace().count();
8448 if !(3..=5).contains(&word_count) || trimmed.len() > 60 {
8449 return false;
8450 }
8451
8452 let Some(first_alpha) = trimmed.chars().find(|ch| ch.is_alphabetic()) else {
8453 return false;
8454 };
8455 if first_alpha.is_lowercase() {
8456 return false;
8457 }
8458
8459 let Some(prev_idx) = idx.checked_sub(1) else {
8460 return false;
8461 };
8462 let prev_text = extract_element_text(&doc.kids[prev_idx]);
8463 let prev_trimmed = prev_text.trim();
8464 !prev_trimmed.is_empty() && prev_trimmed.ends_with('-')
8465}
8466
8467fn looks_like_table_header_duplicate_heading(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8468 let trimmed = text.trim();
8469 if trimmed.is_empty()
8470 || starts_with_caption_prefix(trimmed)
8471 || looks_like_numbered_section(trimmed)
8472 || looks_like_keyword_numbered_section(trimmed)
8473 {
8474 return false;
8475 }
8476
8477 let word_count = trimmed.split_whitespace().count();
8478 if !(3..=10).contains(&word_count) || trimmed.len() > 96 {
8479 return false;
8480 }
8481
8482 let Some(prev_idx) = idx.checked_sub(1) else {
8483 return false;
8484 };
8485 let Some(previous_table) = table_border_from_element(&doc.kids[prev_idx]) else {
8486 return false;
8487 };
8488 if previous_table.num_columns < 3 || previous_table.rows.len() < 3 {
8489 return false;
8490 }
8491
8492 let mut rendered_rows = collect_table_border_rows(previous_table);
8493 if rendered_rows.is_empty() {
8494 return false;
8495 }
8496 merge_continuation_rows(&mut rendered_rows);
8497 trim_leading_table_carryover_rows(&mut rendered_rows);
8498
8499 let Some(header_row) = rendered_rows.first() else {
8500 return false;
8501 };
8502 let header_text = header_row
8503 .iter()
8504 .map(|cell| cell.trim())
8505 .filter(|cell| !cell.is_empty())
8506 .collect::<Vec<_>>()
8507 .join(" ");
8508 if !equivalent_heading_text(trimmed, &header_text) {
8509 return false;
8510 }
8511
8512 let page_number = doc.kids[idx].page_number();
8513 let mut short_fragments = 0usize;
8514 let mut numeric_fragments = 0usize;
8515
8516 for candidate in doc.kids.iter().skip(idx + 1) {
8517 if candidate.page_number() != page_number {
8518 break;
8519 }
8520 if matches!(
8521 candidate,
8522 ContentElement::Table(_) | ContentElement::TableBorder(_)
8523 ) {
8524 break;
8525 }
8526
8527 let fragment = extract_element_text(candidate);
8528 let fragment_trimmed = fragment.trim();
8529 if fragment_trimmed.is_empty()
8530 || looks_like_margin_page_number(doc, candidate, fragment_trimmed)
8531 {
8532 continue;
8533 }
8534
8535 let fragment_words = fragment_trimmed.split_whitespace().count();
8536 if fragment_words > 6 {
8537 return false;
8538 }
8539
8540 short_fragments += 1;
8541 if fragment_trimmed.chars().any(|ch| ch.is_ascii_digit()) {
8542 numeric_fragments += 1;
8543 }
8544
8545 if short_fragments >= 3 {
8546 break;
8547 }
8548 }
8549
8550 short_fragments >= 2 && numeric_fragments >= 1
8551}
8552
8553fn looks_like_top_margin_running_header(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8554 let trimmed = text.trim();
8555 if trimmed.is_empty() || trimmed.split_whitespace().count() > 6 {
8556 return false;
8557 }
8558
8559 let element = &doc.kids[idx];
8560 let bbox = element.bbox();
8561 if bbox.height() > 24.0 {
8562 return false;
8563 }
8564
8565 let Some(page) = element.page_number() else {
8566 return false;
8567 };
8568
8569 let mut page_tops = std::collections::HashMap::<u32, f64>::new();
8571 for candidate in &doc.kids {
8572 if let Some(p) = candidate.page_number() {
8573 let top = page_tops.entry(p).or_insert(f64::MIN);
8574 *top = top.max(candidate.bbox().top_y);
8575 }
8576 }
8577
8578 let page_top = page_tops.get(&page).copied().unwrap_or(0.0);
8579 if bbox.top_y < page_top - 24.0 {
8580 return false;
8581 }
8582
8583 let trimmed_lower = trimmed.to_lowercase();
8587 for other_elem in &doc.kids {
8588 let Some(other_page) = other_elem.page_number() else {
8589 continue;
8590 };
8591 if other_page == page {
8592 continue;
8593 }
8594 let other_bbox = other_elem.bbox();
8595 if other_bbox.height() > 24.0 {
8596 continue;
8597 }
8598 let other_top = page_tops.get(&other_page).copied().unwrap_or(0.0);
8599 if other_bbox.top_y < other_top - 24.0 {
8600 continue;
8601 }
8602 let other_text = match other_elem {
8603 ContentElement::Paragraph(p) => p.base.value(),
8604 ContentElement::TextBlock(tb) => tb.value(),
8605 ContentElement::TextLine(tl) => tl.value(),
8606 ContentElement::Heading(h) => h.base.base.value(),
8607 _ => continue,
8608 };
8609 if other_text.trim().to_lowercase() == trimmed_lower {
8610 return true;
8611 }
8612 }
8613
8614 false
8615}
8616
8617fn looks_like_chart_label_heading(element: &ContentElement, text: &str) -> bool {
8618 let trimmed = text.trim();
8619 let upper_words = trimmed
8620 .split_whitespace()
8621 .filter(|word| word.chars().any(char::is_alphabetic))
8622 .all(|word| {
8623 word.chars()
8624 .filter(|ch| ch.is_alphabetic())
8625 .all(|ch| ch.is_uppercase())
8626 });
8627
8628 (trimmed.contains('%') || upper_words) && element.bbox().height() <= 40.0
8629}
8630
8631fn should_demote_heading_to_paragraph(text: &str, next: &str) -> bool {
8632 let next_trimmed = next.trim();
8633 if !next_trimmed.chars().next().is_some_and(char::is_lowercase) {
8634 return false;
8635 }
8636
8637 let normalized = normalize_heading_text(text);
8638 if matches!(
8639 normalized.as_str(),
8640 "contents" | "tableofcontents" | "introduction" | "conclusion"
8641 ) {
8642 return false;
8643 }
8644
8645 let words: Vec<&str> = text.split_whitespace().collect();
8646 if words.len() < 3 {
8647 return false;
8648 }
8649
8650 words
8651 .last()
8652 .is_some_and(|word| is_sentence_fragment_tail(word))
8653}
8654
8655fn is_sentence_fragment_tail(word: &str) -> bool {
8656 matches!(
8657 word.trim_matches(|c: char| !c.is_alphanumeric())
8658 .to_ascii_lowercase()
8659 .as_str(),
8660 "a" | "an"
8661 | "and"
8662 | "as"
8663 | "at"
8664 | "by"
8665 | "for"
8666 | "from"
8667 | "in"
8668 | "into"
8669 | "of"
8670 | "on"
8671 | "or"
8672 | "that"
8673 | "the"
8674 | "to"
8675 | "with"
8676 )
8677}
8678
8679fn is_list_section_heading(text: &str) -> bool {
8680 let trimmed = text.trim();
8681 trimmed.ends_with(':')
8682 && trimmed.len() <= 80
8683 && trimmed.split_whitespace().count() <= 8
8684 && trimmed.chars().any(char::is_alphabetic)
8685 && !trimmed.chars().next().is_some_and(|c| c.is_ascii_digit())
8686 && !trimmed.starts_with(|c: char| "•‣◦●○◆◇▪▫–—-".contains(c))
8687}
8688
8689fn should_merge_paragraph_text(prev: &str, next: &str) -> bool {
8690 let next_trimmed = next.trim();
8691 if next_trimmed.is_empty() || is_standalone_page_number(next_trimmed) {
8692 return false;
8693 }
8694
8695 if starts_with_enumerated_marker(next_trimmed) {
8696 return false;
8697 }
8698
8699 if prev.ends_with('-')
8700 && prev.chars().rev().nth(1).is_some_and(|c| c.is_alphabetic())
8701 && next_trimmed.chars().next().is_some_and(char::is_lowercase)
8702 {
8703 return true;
8704 }
8705
8706 if next_trimmed.chars().next().is_some_and(char::is_lowercase) {
8707 return true;
8708 }
8709
8710 let lower = next_trimmed.to_ascii_lowercase();
8711 if lower.starts_with("http://")
8712 || lower.starts_with("https://")
8713 || lower.starts_with("arxiv")
8714 || lower.starts_with("doi:")
8715 {
8716 return true;
8717 }
8718
8719 if matches!(
8720 next_trimmed.split_whitespace().next(),
8721 Some("In" | "Proceedings" | "Advances" | "Learning")
8722 ) {
8723 return true;
8724 }
8725
8726 !prev.ends_with(['.', '!', '?', ':'])
8727}
8728
8729fn should_merge_adjacent_semantic_paragraphs(prev: &str, next: &str) -> bool {
8730 let next_trimmed = next.trim();
8731 if next_trimmed.is_empty() {
8732 return false;
8733 }
8734
8735 if starts_with_enumerated_marker(next_trimmed) {
8736 return false;
8737 }
8738
8739 if prev.ends_with('-')
8740 && prev.chars().rev().nth(1).is_some_and(|c| c.is_alphabetic())
8741 && next_trimmed.chars().next().is_some_and(char::is_lowercase)
8742 {
8743 return true;
8744 }
8745
8746 next_trimmed.chars().next().is_some_and(char::is_lowercase)
8747}
8748
8749fn starts_with_enumerated_marker(text: &str) -> bool {
8750 let first_token = match text.split_whitespace().next() {
8751 Some(token) => token.trim_start_matches(['(', '[']),
8752 None => return false,
8753 };
8754 if !first_token.ends_with(['.', ')', ':']) {
8755 return false;
8756 }
8757
8758 let marker = first_token.trim_end_matches(['.', ')', ':']);
8759 if marker.is_empty() {
8760 return false;
8761 }
8762
8763 if marker.chars().all(|c| c.is_ascii_digit()) {
8764 return true;
8765 }
8766
8767 if marker.len() == 1 && marker.chars().all(|c| c.is_ascii_alphabetic()) {
8768 return true;
8769 }
8770
8771 let lower = marker.to_ascii_lowercase();
8772 lower.len() <= 8 && lower.chars().all(|c| "ivxlcdm".contains(c))
8773}
8774
8775fn should_skip_leading_figure_carryover(doc: &PdfDocument, idx: usize, text: &str) -> bool {
8776 let trimmed = text.trim();
8777 if !trimmed.starts_with("Figure ") || trimmed.split_whitespace().count() < 4 {
8778 return false;
8779 }
8780
8781 let element = &doc.kids[idx];
8782 let Some(page) = element.page_number() else {
8783 return false;
8784 };
8785
8786 let mut page_top = f64::MIN;
8787 for candidate in &doc.kids {
8788 if candidate.page_number() == Some(page)
8789 && matches!(
8790 candidate,
8791 ContentElement::Paragraph(_)
8792 | ContentElement::TextBlock(_)
8793 | ContentElement::TextLine(_)
8794 | ContentElement::Heading(_)
8795 | ContentElement::NumberHeading(_)
8796 | ContentElement::Caption(_)
8797 )
8798 {
8799 page_top = page_top.max(candidate.bbox().top_y);
8800 }
8801 }
8802 if !page_top.is_finite() || element.bbox().top_y < page_top - 72.0 {
8803 return false;
8804 }
8805
8806 for prior_idx in 0..idx {
8807 let prior = &doc.kids[prior_idx];
8808 let prior_text = extract_element_text(prior);
8809 let prior_trimmed = prior_text.trim();
8810 if prior_trimmed.is_empty()
8811 || is_standalone_page_number(prior_trimmed)
8812 || looks_like_footer_banner(prior_trimmed)
8813 {
8814 continue;
8815 }
8816 match prior {
8817 ContentElement::Paragraph(_)
8818 | ContentElement::TextBlock(_)
8819 | ContentElement::TextLine(_) => {
8820 if !starts_with_caption_prefix(prior_trimmed)
8821 && !looks_like_top_margin_running_header(doc, prior_idx, prior_trimmed)
8822 {
8823 return false;
8824 }
8825 }
8826 ContentElement::Heading(_) | ContentElement::NumberHeading(_) => {
8827 if !should_skip_heading_text(prior_trimmed) {
8828 return false;
8829 }
8830 }
8831 _ => return false,
8832 }
8833 }
8834
8835 for lookahead_idx in idx + 1..doc.kids.len().min(idx + 8) {
8836 let next = &doc.kids[lookahead_idx];
8837 if next.page_number() != Some(page) {
8838 break;
8839 }
8840 let next_text = extract_element_text(next);
8841 let next_trimmed = next_text.trim();
8842 if next_trimmed.is_empty() || is_standalone_page_number(next_trimmed) {
8843 continue;
8844 }
8845
8846 let is_numbered_heading = match next {
8847 ContentElement::Heading(_) | ContentElement::NumberHeading(_) => {
8848 looks_like_numbered_section(next_trimmed)
8849 || looks_like_keyword_numbered_section(next_trimmed)
8850 }
8851 ContentElement::Paragraph(_)
8852 | ContentElement::TextBlock(_)
8853 | ContentElement::TextLine(_) => {
8854 should_render_paragraph_as_heading(
8855 doc,
8856 lookahead_idx,
8857 next_trimmed,
8858 doc.kids.get(lookahead_idx + 1),
8859 ) && (looks_like_numbered_section(next_trimmed)
8860 || looks_like_keyword_numbered_section(next_trimmed))
8861 }
8862 _ => false,
8863 };
8864
8865 if is_numbered_heading {
8866 return true;
8867 }
8868
8869 if !starts_with_caption_prefix(next_trimmed) && next_trimmed.split_whitespace().count() >= 5
8870 {
8871 return false;
8872 }
8873 }
8874
8875 false
8876}
8877
8878fn merge_paragraph_text(target: &mut String, next: &str) {
8879 let next_trimmed = next.trim();
8880 if target.ends_with('-')
8881 && target
8882 .chars()
8883 .rev()
8884 .nth(1)
8885 .is_some_and(|c| c.is_alphabetic())
8886 && next_trimmed.chars().next().is_some_and(char::is_lowercase)
8887 {
8888 target.pop();
8889 target.push_str(next_trimmed);
8890 } else {
8891 if !target.ends_with(' ') {
8892 target.push(' ');
8893 }
8894 target.push_str(next_trimmed);
8895 }
8896}
8897
8898fn is_standalone_page_number(text: &str) -> bool {
8899 let trimmed = text.trim();
8900 !trimmed.is_empty() && trimmed.len() <= 4 && trimmed.chars().all(|c| c.is_ascii_digit())
8901}
8902
8903fn looks_like_margin_page_number(doc: &PdfDocument, element: &ContentElement, text: &str) -> bool {
8904 if !is_standalone_page_number(text) {
8905 return false;
8906 }
8907
8908 let bbox = element.bbox();
8909 if bbox.height() > 24.0 {
8910 return false;
8911 }
8912
8913 let Some(page) = element.page_number() else {
8914 return false;
8915 };
8916
8917 let mut page_top = f64::MIN;
8918 let mut page_bottom = f64::MAX;
8919 for candidate in &doc.kids {
8920 if candidate.page_number() == Some(page) {
8921 let candidate_bbox = candidate.bbox();
8922 page_top = page_top.max(candidate_bbox.top_y);
8923 page_bottom = page_bottom.min(candidate_bbox.bottom_y);
8924 }
8925 }
8926
8927 if !page_top.is_finite() || !page_bottom.is_finite() {
8928 return false;
8929 }
8930
8931 bbox.top_y >= page_top - 24.0 || bbox.bottom_y <= page_bottom + 24.0
8932}
8933
8934fn looks_like_bottom_margin_heading(doc: &PdfDocument, idx: usize) -> bool {
8939 let element = &doc.kids[idx];
8940 let bbox = element.bbox();
8941 if bbox.height() > 30.0 {
8942 return false;
8943 }
8944
8945 let Some(page) = element.page_number() else {
8946 return false;
8947 };
8948
8949 let mut page_bottom = f64::MAX;
8950 for candidate in &doc.kids {
8951 if candidate.page_number() == Some(page) {
8952 page_bottom = page_bottom.min(candidate.bbox().bottom_y);
8953 }
8954 }
8955
8956 if !page_bottom.is_finite() {
8957 return false;
8958 }
8959
8960 bbox.bottom_y <= page_bottom + 24.0
8962}
8963
8964fn should_demote_period_heading(text: &str) -> bool {
8968 let trimmed = text.trim();
8969 if !trimmed.ends_with('.') {
8970 return false;
8971 }
8972 if looks_like_numbered_section(trimmed) || looks_like_keyword_numbered_section(trimmed) {
8975 return false;
8976 }
8977 let without_dot = trimmed.trim_end_matches('.');
8981 let word_count = without_dot.split_whitespace().count();
8982 if word_count <= 2 {
8985 return true;
8986 }
8987 false
8988}
8989
8990fn should_demote_comma_heading(text: &str) -> bool {
8993 text.trim().ends_with(',')
8994}
8995
8996fn should_demote_math_heading(text: &str) -> bool {
8999 text.chars().any(|c| {
9000 matches!(
9001 c,
9002 '¼' | '½'
9003 | '¾'
9004 | '≪'
9005 | '≫'
9006 | 'þ'
9007 | 'ð'
9008 | '∑'
9009 | '∫'
9010 | '∂'
9011 | '∏'
9012 | '√'
9013 | '∞'
9014 | '≈'
9015 | '÷'
9016 )
9017 })
9018}
9019
9020fn should_demote_percentage_heading(text: &str) -> bool {
9023 text.contains('%')
9024}
9025
9026fn should_demote_bibliography_heading(text: &str) -> bool {
9029 let t = text.trim();
9030 if t.len() < 6 {
9031 return false;
9032 }
9033 let bytes = t.as_bytes();
9034 bytes[0..4].iter().all(|b| b.is_ascii_digit())
9035 && bytes[4] == b'.'
9036 && (bytes[5] == b' ' || t.len() == 5)
9037}
9038
9039fn strip_trailing_page_number(text: &str) -> &str {
9044 let trimmed = text.trim();
9045 if let Some(last_space) = trimmed.rfind(' ') {
9046 let suffix = &trimmed[last_space + 1..];
9047 if !suffix.is_empty()
9048 && suffix.len() <= 4
9049 && suffix.chars().all(|c| c.is_ascii_digit())
9050 && trimmed[..last_space].split_whitespace().count() >= 3
9051 {
9052 return trimmed[..last_space].trim();
9053 }
9054 }
9055 trimmed
9056}
9057
9058fn find_merged_subsection_split(text: &str) -> Option<usize> {
9063 let bytes = text.as_bytes();
9066 let mut i = 3;
9068 while i < bytes.len() {
9069 if bytes[i - 1] == b' ' {
9070 if bytes[i].is_ascii_digit() {
9072 if let Some(dot_pos) = text[i..].find('.') {
9073 let after_dot = i + dot_pos + 1;
9074 if after_dot < bytes.len() && bytes[after_dot].is_ascii_digit() {
9075 return Some(i);
9077 }
9078 }
9079 }
9080 if bytes[i].is_ascii_uppercase()
9082 && i + 2 < bytes.len()
9083 && bytes[i + 1] == b'.'
9084 && bytes[i + 2].is_ascii_digit()
9085 {
9086 return Some(i);
9087 }
9088 }
9089 i += 1;
9090 }
9091 None
9092}
9093
9094fn should_skip_heading_text(text: &str) -> bool {
9095 let trimmed = text.trim();
9096 if trimmed.is_empty() || is_standalone_page_number(trimmed) {
9097 return true;
9098 }
9099
9100 let lower = trimmed.to_ascii_lowercase();
9101 if (lower.starts_with("chapter ") || lower.chars().next().is_some_and(|c| c.is_ascii_digit()))
9102 && trimmed.contains('|')
9103 {
9104 return true;
9105 }
9106
9107 let alpha_count = trimmed.chars().filter(|c| c.is_alphabetic()).count();
9108 let alnum_count = trimmed.chars().filter(|c| c.is_alphanumeric()).count();
9109 alpha_count == 0 || (alnum_count > 0 && alpha_count * 3 < alnum_count && !trimmed.contains(':'))
9110}
9111
9112fn repair_fragmented_words(text: &str) -> String {
9113 const STOPWORDS: &[&str] = &[
9114 "a", "an", "and", "are", "as", "at", "be", "by", "can", "for", "from", "if", "in", "into",
9115 "is", "it", "may", "must", "not", "of", "on", "or", "per", "that", "the", "to", "with",
9116 ];
9117
9118 let mut parts: Vec<String> = text.split_whitespace().map(str::to_string).collect();
9119 if parts.len() < 2 {
9120 return text.to_string();
9121 }
9122
9123 let mut i = 0usize;
9124 while i + 1 < parts.len() {
9125 let left = parts[i].clone();
9126 let right = parts[i + 1].clone();
9127 let left_clean = left.trim_matches(|c: char| !c.is_alphabetic());
9128 let right_clean = right.trim_matches(|c: char| !c.is_alphabetic());
9129 let left_lower = left_clean.to_ascii_lowercase();
9130 let right_lower = right_clean.to_ascii_lowercase();
9131
9132 let should_join = !left_clean.is_empty()
9133 && !right_clean.is_empty()
9134 && left_clean.chars().all(char::is_alphabetic)
9135 && right_clean.chars().all(char::is_alphabetic)
9136 && (left_clean.len() <= 4 || right_clean.len() <= 4)
9137 && left_clean.len() + right_clean.len() >= 6
9138 && !right_clean.chars().next().is_some_and(char::is_uppercase)
9139 && !STOPWORDS.contains(&left_lower.as_str())
9140 && !STOPWORDS.contains(&right_lower.as_str());
9141
9142 if should_join {
9143 let next = parts.remove(i + 1);
9144 parts[i].push_str(&next);
9145 } else {
9146 i += 1;
9147 }
9148 }
9149
9150 parts.join(" ")
9151}
9152
9153fn list_item_text_from_contents(contents: &[ContentElement]) -> String {
9155 let mut text = String::new();
9156 for elem in contents {
9157 let part = match elem {
9158 ContentElement::Paragraph(p) => p.base.value(),
9159 ContentElement::TextBlock(tb) => tb.value(),
9160 ContentElement::TextLine(tl) => tl.value(),
9161 ContentElement::TextChunk(tc) => tc.value.clone(),
9162 _ => String::new(),
9163 };
9164 if !text.is_empty() && !part.is_empty() {
9165 text.push(' ');
9166 }
9167 text.push_str(&part);
9168 }
9169 text
9170}
9171
9172fn has_internal_header_gap(row: &[String]) -> bool {
9173 let mut seen_filled = false;
9174 let mut seen_gap_after_fill = false;
9175 for cell in row {
9176 if cell.trim().is_empty() {
9177 if seen_filled {
9178 seen_gap_after_fill = true;
9179 }
9180 continue;
9181 }
9182 if seen_gap_after_fill {
9183 return true;
9184 }
9185 seen_filled = true;
9186 }
9187 false
9188}
9189
9190fn expand_grouped_header_row(parent: &[String], child: &[String]) -> Vec<String> {
9191 let anchor_cols: Vec<usize> = parent
9192 .iter()
9193 .enumerate()
9194 .filter_map(|(idx, cell)| (!cell.trim().is_empty()).then_some(idx))
9195 .collect();
9196 if anchor_cols.is_empty() {
9197 return parent.to_vec();
9198 }
9199
9200 let mut expanded = parent.to_vec();
9201 for (col_idx, child_cell) in child.iter().enumerate() {
9202 if !expanded[col_idx].trim().is_empty() || child_cell.trim().is_empty() {
9203 continue;
9204 }
9205
9206 let mut best_anchor = anchor_cols[0];
9207 let mut best_distance = usize::abs_diff(anchor_cols[0], col_idx);
9208 for &anchor_idx in &anchor_cols[1..] {
9209 let distance = usize::abs_diff(anchor_idx, col_idx);
9210 if distance < best_distance || (distance == best_distance && anchor_idx > best_anchor) {
9211 best_anchor = anchor_idx;
9212 best_distance = distance;
9213 }
9214 }
9215 expanded[col_idx] = parent[best_anchor].trim().to_string();
9216 }
9217
9218 expanded
9219}
9220
9221fn preserve_grouped_header_rows(rows: &mut [Vec<String>]) -> bool {
9222 if rows.len() < 2 || rows[0].is_empty() || rows[1].is_empty() {
9223 return false;
9224 }
9225 if rows[0].first().is_none_or(|cell| cell.trim().is_empty()) {
9226 return false;
9227 }
9228 if rows[1].first().is_some_and(|cell| !cell.trim().is_empty()) {
9229 return false;
9230 }
9231
9232 let first_filled = rows[0]
9233 .iter()
9234 .filter(|cell| !cell.trim().is_empty())
9235 .count();
9236 let second_filled = rows[1]
9237 .iter()
9238 .filter(|cell| !cell.trim().is_empty())
9239 .count();
9240 if first_filled < 2 || second_filled <= first_filled || !has_internal_header_gap(&rows[0]) {
9241 return false;
9242 }
9243
9244 rows[0] = expand_grouped_header_row(&rows[0], &rows[1]);
9245 true
9246}
9247
9248fn merge_continuation_rows(rows: &mut Vec<Vec<String>>) {
9259 if rows.len() < 2 {
9260 return;
9261 }
9262 if preserve_grouped_header_rows(rows) {
9263 return;
9264 }
9265 if rows[0].first().is_none_or(|c| c.trim().is_empty()) {
9267 return;
9268 }
9269
9270 let mut merge_count = 0usize;
9271 for (i, row_i) in rows.iter().enumerate().skip(1) {
9272 let first_empty = row_i.first().is_none_or(|c| c.trim().is_empty());
9273 if !first_empty {
9274 break; }
9276 let all_short = row_i
9278 .iter()
9279 .all(|c| c.trim().is_empty() || c.trim().len() <= 30);
9280 if !all_short {
9281 break;
9282 }
9283 merge_count = i;
9284 }
9285
9286 if merge_count == 0 {
9289 return;
9290 }
9291
9292 for i in 1..=merge_count {
9294 let (head, tail) = rows.split_at_mut(i);
9295 let ncols = head[0].len().min(tail[0].len());
9296 for (target, src) in head[0]
9297 .iter_mut()
9298 .take(ncols)
9299 .zip(tail[0].iter().take(ncols))
9300 {
9301 let fragment = src.trim().to_string();
9302 if !fragment.is_empty() {
9303 let target_str = target.trim().to_string();
9304 *target = if target_str.is_empty() {
9305 fragment
9306 } else {
9307 format!("{} {}", target_str, fragment)
9308 };
9309 }
9310 }
9311 }
9312
9313 rows.drain(1..=merge_count);
9315}
9316
9317fn trim_leading_table_carryover_rows(rows: &mut Vec<Vec<String>>) {
9318 while first_body_row_looks_like_carryover(rows) {
9319 rows.remove(1);
9320 }
9321}
9322
9323fn first_body_row_looks_like_carryover(rows: &[Vec<String>]) -> bool {
9324 if rows.len() < 3 {
9325 return false;
9326 }
9327
9328 let key_col_count = infer_leading_key_column_count(&rows[1..]);
9329 if key_col_count == 0 {
9330 return false;
9331 }
9332
9333 let candidate = &rows[1];
9334 if candidate
9335 .iter()
9336 .take(key_col_count)
9337 .any(|cell| !cell.trim().is_empty())
9338 {
9339 return false;
9340 }
9341
9342 let non_empty_cols = candidate
9343 .iter()
9344 .enumerate()
9345 .filter(|(_, cell)| !cell.trim().is_empty())
9346 .map(|(idx, _)| idx)
9347 .collect::<Vec<_>>();
9348 if non_empty_cols.len() != 1 {
9349 return false;
9350 }
9351
9352 let only_col = non_empty_cols[0];
9353 if only_col < key_col_count {
9354 return false;
9355 }
9356
9357 if candidate[only_col].split_whitespace().count() < 4 {
9358 return false;
9359 }
9360
9361 rows[2]
9362 .iter()
9363 .take(key_col_count)
9364 .all(|cell| !cell.trim().is_empty())
9365}
9366
9367fn infer_leading_key_column_count(rows: &[Vec<String>]) -> usize {
9368 if rows.len() < 2 {
9369 return 0;
9370 }
9371
9372 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
9373 let mut key_cols = 0usize;
9374
9375 for col_idx in 0..num_cols {
9376 let mut occupancy = 0usize;
9377 let mut word_counts = Vec::new();
9378
9379 for row in rows {
9380 let cell = row.get(col_idx).map(String::as_str).unwrap_or("");
9381 let trimmed = cell.trim();
9382 if trimmed.is_empty() {
9383 continue;
9384 }
9385 occupancy += 1;
9386 word_counts.push(trimmed.split_whitespace().count());
9387 }
9388
9389 if occupancy == 0 {
9390 break;
9391 }
9392
9393 word_counts.sort_unstable();
9394 let median_words = word_counts[word_counts.len() / 2];
9395 let occupancy_ratio = occupancy as f64 / rows.len() as f64;
9396 if occupancy_ratio < 0.6 || median_words > 3 {
9397 break;
9398 }
9399 key_cols += 1;
9400 }
9401
9402 key_cols
9403}
9404
9405fn render_table(out: &mut String, table: &crate::models::semantic::SemanticTable) {
9407 render_table_border(out, &table.table_border);
9409}
9410
9411#[derive(Clone, Debug)]
9412struct GeometricTableRegion {
9413 start_idx: usize,
9414 end_idx: usize,
9415 rendered: String,
9416}
9417
9418#[derive(Clone)]
9419struct ChunkLine {
9420 bbox: BoundingBox,
9421 chunks: Vec<TextChunk>,
9422}
9423
9424#[derive(Clone)]
9425struct SlotFragment {
9426 slot_idx: usize,
9427 bbox: BoundingBox,
9428 text: String,
9429}
9430
9431fn detect_geometric_table_regions(doc: &PdfDocument) -> Vec<GeometricTableRegion> {
9432 let mut regions = Vec::new();
9433 let mut occupied_until = 0usize;
9434
9435 for (idx, element) in doc.kids.iter().enumerate() {
9436 if idx < occupied_until {
9437 continue;
9438 }
9439
9440 let Some(table) = table_border_from_element(element) else {
9441 continue;
9442 };
9443 let Some(region) = build_geometric_table_region(doc, idx, table) else {
9444 continue;
9445 };
9446 occupied_until = region.end_idx.saturating_add(1);
9447 regions.push(region);
9448 }
9449
9450 let mut occupied = regions
9451 .iter()
9452 .flat_map(|region| region.start_idx..=region.end_idx)
9453 .collect::<HashSet<_>>();
9454 for region in detect_footnote_citation_regions(doc) {
9455 if (region.start_idx..=region.end_idx).any(|idx| occupied.contains(&idx)) {
9456 continue;
9457 }
9458 occupied.extend(region.start_idx..=region.end_idx);
9459 regions.push(region);
9460 }
9461
9462 regions.sort_by_key(|region| region.start_idx);
9463 regions
9464}
9465
9466fn detect_footnote_citation_regions(doc: &PdfDocument) -> Vec<GeometricTableRegion> {
9467 let body_font_size = compute_running_body_font_size(doc);
9468 if body_font_size <= 0.0 {
9469 return Vec::new();
9470 }
9471
9472 let mut regions = Vec::new();
9473 let mut idx = 0usize;
9474 while idx < doc.kids.len() {
9475 let Some(region) = build_footnote_citation_region(doc, idx, body_font_size) else {
9476 idx += 1;
9477 continue;
9478 };
9479 idx = region.end_idx.saturating_add(1);
9480 regions.push(region);
9481 }
9482
9483 regions
9484}
9485
9486fn compute_running_body_font_size(doc: &PdfDocument) -> f64 {
9487 doc.kids
9488 .iter()
9489 .filter_map(|element| {
9490 let ContentElement::Paragraph(paragraph) = element else {
9491 return None;
9492 };
9493 let text = paragraph.base.value();
9494 (text.split_whitespace().count() > 10).then_some(paragraph.base.font_size?)
9495 })
9496 .fold(0.0_f64, f64::max)
9497}
9498
9499fn build_footnote_citation_region(
9500 doc: &PdfDocument,
9501 start_idx: usize,
9502 body_font_size: f64,
9503) -> Option<GeometricTableRegion> {
9504 let element = doc.kids.get(start_idx)?;
9505 if !is_geometric_text_candidate(element) {
9506 return None;
9507 }
9508
9509 let start_text = extract_element_text(element);
9510 let trimmed_start = start_text.trim();
9511 if trimmed_start.is_empty() {
9512 return None;
9513 }
9514
9515 let small_font_threshold = (body_font_size * 0.92).min(body_font_size - 0.8).max(0.0);
9516 let mut lead_prefix = None;
9517 let mut fragments = Vec::new();
9518 let page_number = element.page_number()?;
9519 let mut column_bbox = element.bbox().clone();
9520 let mut region_start_idx = start_idx;
9521 let mut end_idx = start_idx;
9522
9523 if element_font_size(element).is_some_and(|font_size| font_size <= small_font_threshold)
9524 && starts_with_footnote_marker(trimmed_start)
9525 {
9526 if let Some((attach_idx, prefix, leading_fragments)) = leading_footnote_attachment(
9527 doc,
9528 start_idx,
9529 page_number,
9530 &column_bbox,
9531 small_font_threshold,
9532 ) {
9533 lead_prefix = Some(prefix);
9534 fragments.extend(leading_fragments);
9535 region_start_idx = attach_idx;
9536 }
9537 fragments.push(footnote_fragment_text(element));
9538 } else {
9539 let (prefix, first_tail) = split_trailing_footnote_lead(trimmed_start)?;
9540 let next = doc.kids.get(start_idx + 1)?;
9541 if !is_geometric_text_candidate(next)
9542 || next.page_number() != Some(page_number)
9543 || !element_font_size(next).is_some_and(|font_size| font_size <= small_font_threshold)
9544 {
9545 return None;
9546 }
9547 if !same_column_region(&column_bbox, next.bbox()) {
9548 return None;
9549 }
9550 lead_prefix = Some(prefix);
9551 fragments.push(first_tail);
9552 }
9553
9554 let mut consecutive_small = 0usize;
9555 for idx in start_idx + 1..doc.kids.len() {
9556 let candidate = &doc.kids[idx];
9557 if !is_geometric_text_candidate(candidate) || candidate.page_number() != Some(page_number) {
9558 break;
9559 }
9560
9561 let candidate_text = extract_element_text(candidate);
9562 let trimmed = candidate_text.trim();
9563 if trimmed.is_empty() || starts_with_caption_prefix(trimmed) {
9564 break;
9565 }
9566
9567 let Some(font_size) = element_font_size(candidate) else {
9568 break;
9569 };
9570 if font_size > small_font_threshold {
9571 break;
9572 }
9573 if !same_column_region(&column_bbox, candidate.bbox()) {
9574 break;
9575 }
9576
9577 column_bbox = column_bbox.union(candidate.bbox());
9578 fragments.push(footnote_fragment_text(candidate));
9579 consecutive_small += 1;
9580 end_idx = idx;
9581 }
9582
9583 if consecutive_small == 0 && lead_prefix.is_some() {
9584 return None;
9585 }
9586
9587 let rows = parse_footnote_citation_rows(&fragments);
9588 if rows.len() < 3 {
9589 return None;
9590 }
9591
9592 let numeric_markers = rows
9593 .iter()
9594 .filter_map(|(marker, _)| marker.parse::<u32>().ok())
9595 .collect::<Vec<_>>();
9596 if numeric_markers.len() != rows.len() {
9597 return None;
9598 }
9599 let sequential_steps = numeric_markers
9600 .windows(2)
9601 .filter(|pair| pair[1] == pair[0] + 1)
9602 .count();
9603 if sequential_steps + 1 < rows.len().saturating_sub(1) {
9604 return None;
9605 }
9606
9607 let mut rendered_rows = vec![vec!["Footnote".to_string(), "Citation".to_string()]];
9608 rendered_rows.extend(
9609 rows.into_iter()
9610 .map(|(marker, citation)| vec![marker, citation]),
9611 );
9612
9613 let mut rendered = String::new();
9614 if let Some(prefix) = lead_prefix {
9615 rendered.push_str(&escape_md_line_start(prefix.trim()));
9616 rendered.push_str("\n\n");
9617 }
9618 rendered.push_str(&render_html_table(&rendered_rows));
9619
9620 Some(GeometricTableRegion {
9621 start_idx: region_start_idx,
9622 end_idx,
9623 rendered,
9624 })
9625}
9626
9627fn leading_footnote_attachment(
9628 doc: &PdfDocument,
9629 start_idx: usize,
9630 page_number: u32,
9631 column_bbox: &BoundingBox,
9632 small_font_threshold: f64,
9633) -> Option<(usize, String, Vec<String>)> {
9634 let mut idx = start_idx.checked_sub(1)?;
9635 let mut leading_fragments = Vec::new();
9636 let mut scanned = 0usize;
9637
9638 loop {
9639 let candidate = doc.kids.get(idx)?;
9640 scanned += 1;
9641 if scanned > 6 || candidate.page_number() != Some(page_number) {
9642 return None;
9643 }
9644
9645 if !is_geometric_text_candidate(candidate) {
9646 if idx == 0 {
9647 return None;
9648 }
9649 idx -= 1;
9650 continue;
9651 }
9652
9653 let text = extract_element_text(candidate);
9654 let trimmed = text.trim();
9655 if trimmed.is_empty() {
9656 if idx == 0 {
9657 return None;
9658 }
9659 idx -= 1;
9660 continue;
9661 }
9662 if !same_column_region(candidate.bbox(), column_bbox) {
9663 return None;
9664 }
9665
9666 if element_font_size(candidate).is_some_and(|font_size| font_size <= small_font_threshold) {
9667 leading_fragments.push(footnote_fragment_text(candidate));
9668 if idx == 0 {
9669 return None;
9670 }
9671 idx -= 1;
9672 continue;
9673 }
9674
9675 let (prefix, first_tail) = split_trailing_footnote_lead(trimmed)?;
9676 leading_fragments.push(first_tail);
9677 leading_fragments.reverse();
9678 return Some((idx, prefix, leading_fragments));
9679 }
9680}
9681
9682fn parse_footnote_citation_rows(fragments: &[String]) -> Vec<(String, String)> {
9683 let mut rows = Vec::new();
9684 let mut current_marker = None::<String>;
9685 let mut current_citation = String::new();
9686
9687 for fragment in fragments {
9688 let markers = find_footnote_marker_positions(fragment);
9689 if markers.is_empty() {
9690 if current_marker.is_some() {
9691 merge_paragraph_text(&mut current_citation, fragment.trim());
9692 }
9693 continue;
9694 }
9695
9696 let mut cursor = 0usize;
9697 for (pos, marker, skip_len) in markers {
9698 let prefix = fragment[cursor..pos].trim();
9699 if current_marker.is_some() && !prefix.is_empty() {
9700 merge_paragraph_text(&mut current_citation, prefix);
9701 }
9702 if let Some(marker_value) = current_marker.take() {
9703 let trimmed = current_citation.trim();
9704 if !trimmed.is_empty() {
9705 rows.push((marker_value, trimmed.to_string()));
9706 }
9707 current_citation.clear();
9708 }
9709 current_marker = Some(marker);
9710 cursor = pos + skip_len;
9711 }
9712
9713 let tail = fragment[cursor..].trim();
9714 if current_marker.is_some() && !tail.is_empty() {
9715 merge_paragraph_text(&mut current_citation, tail);
9716 }
9717 }
9718
9719 if let Some(marker_value) = current_marker {
9720 let trimmed = current_citation.trim();
9721 if !trimmed.is_empty() {
9722 rows.push((marker_value, trimmed.to_string()));
9723 }
9724 }
9725
9726 rebalance_adjacent_footnote_citations(&mut rows);
9727 rows
9728}
9729
9730fn rebalance_adjacent_footnote_citations(rows: &mut [(String, String)]) {
9731 for idx in 0..rows.len().saturating_sub(1) {
9732 if !rows[idx].1.trim_end().ends_with(',') {
9733 continue;
9734 }
9735
9736 let next = rows[idx + 1].1.trim().to_string();
9737 let Some((stub, remainder)) = split_leading_citation_stub(&next) else {
9738 continue;
9739 };
9740 let Some((first_sentence, trailing)) = split_first_sentence(remainder) else {
9741 continue;
9742 };
9743 if first_sentence.split_whitespace().count() < 2 {
9744 continue;
9745 }
9746
9747 merge_paragraph_text(&mut rows[idx].1, first_sentence);
9748 rows[idx + 1].1 = if trailing.is_empty() {
9749 stub.to_string()
9750 } else {
9751 format!("{stub} {trailing}")
9752 };
9753 }
9754}
9755
9756fn split_leading_citation_stub(text: &str) -> Option<(&str, &str)> {
9757 let comma_idx = text.find(',')?;
9758 if comma_idx > 8 {
9759 return None;
9760 }
9761 let stub = text[..=comma_idx].trim();
9762 let remainder = text[comma_idx + 1..].trim();
9763 (!stub.is_empty() && !remainder.is_empty()).then_some((stub, remainder))
9764}
9765
9766fn split_first_sentence(text: &str) -> Option<(&str, &str)> {
9767 let period_idx = text.find(". ")?;
9768 let first = text[..=period_idx].trim();
9769 let trailing = text[period_idx + 2..].trim();
9770 (!first.is_empty()).then_some((first, trailing))
9771}
9772
9773fn find_footnote_marker_positions(text: &str) -> Vec<(usize, String, usize)> {
9774 let chars = text.char_indices().collect::<Vec<_>>();
9775 let mut markers = Vec::new();
9776 let mut idx = 0usize;
9777
9778 while idx < chars.len() {
9779 let (byte_idx, ch) = chars[idx];
9780 if !ch.is_ascii_digit() {
9781 idx += 1;
9782 continue;
9783 }
9784
9785 let at_boundary = idx == 0
9786 || chars[idx - 1].1.is_whitespace()
9787 || matches!(
9788 chars[idx - 1].1,
9789 '.' | ',' | ';' | ':' | ')' | ']' | '"' | '\'' | '”'
9790 );
9791 if !at_boundary {
9792 idx += 1;
9793 continue;
9794 }
9795
9796 let mut end_idx = idx;
9797 while end_idx < chars.len() && chars[end_idx].1.is_ascii_digit() {
9798 end_idx += 1;
9799 }
9800 let digits = &text[byte_idx
9801 ..chars
9802 .get(end_idx)
9803 .map(|(pos, _)| *pos)
9804 .unwrap_or(text.len())];
9805 if digits.len() > 2 || end_idx >= chars.len() || !chars[end_idx].1.is_whitespace() {
9806 idx += 1;
9807 continue;
9808 }
9809
9810 let mut lookahead = end_idx;
9811 while lookahead < chars.len() && chars[lookahead].1.is_whitespace() {
9812 lookahead += 1;
9813 }
9814 let Some((_, next_ch)) = chars.get(lookahead) else {
9815 idx += 1;
9816 continue;
9817 };
9818 if !(next_ch.is_ascii_uppercase() || matches!(*next_ch, '(' | '[' | '*')) {
9819 idx += 1;
9820 continue;
9821 }
9822
9823 let skip_end = chars
9824 .get(lookahead)
9825 .map(|(pos, _)| *pos)
9826 .unwrap_or(text.len());
9827 markers.push((byte_idx, digits.to_string(), skip_end - byte_idx));
9828 idx = lookahead;
9829 }
9830
9831 markers
9832}
9833
9834fn split_trailing_footnote_lead(text: &str) -> Option<(String, String)> {
9835 let markers = find_footnote_marker_positions(text);
9836 let (pos, marker, skip_len) = markers.last()?.clone();
9837 let prefix = text[..pos].trim();
9838 let tail = text[pos + skip_len..].trim();
9839 if prefix.split_whitespace().count() < 6 || tail.split_whitespace().count() > 6 {
9840 return None;
9841 }
9842 Some((prefix.to_string(), format!("{marker} {tail}")))
9843}
9844
9845fn starts_with_footnote_marker(text: &str) -> bool {
9846 find_footnote_marker_positions(text)
9847 .first()
9848 .is_some_and(|(pos, _, _)| *pos == 0)
9849}
9850
9851fn same_column_region(left: &BoundingBox, right: &BoundingBox) -> bool {
9852 let overlap = (left.right_x.min(right.right_x) - left.left_x.max(right.left_x)).max(0.0);
9853 let min_width = left.width().min(right.width()).max(1.0);
9854 overlap / min_width >= 0.35 || (left.left_x - right.left_x).abs() <= 28.0
9855}
9856
9857fn footnote_fragment_text(element: &ContentElement) -> String {
9858 let text = extract_element_text(element);
9859 if element_font_name(element)
9860 .as_deref()
9861 .is_some_and(|name| name.to_ascii_lowercase().contains("italic"))
9862 {
9863 format!("*{}*", text.trim())
9864 } else {
9865 text
9866 }
9867}
9868
9869fn element_font_size(element: &ContentElement) -> Option<f64> {
9870 match element {
9871 ContentElement::Paragraph(p) => p.base.font_size,
9872 ContentElement::Heading(h) => h.base.base.font_size,
9873 ContentElement::NumberHeading(nh) => nh.base.base.base.font_size,
9874 ContentElement::TextBlock(tb) => Some(tb.font_size),
9875 ContentElement::TextLine(tl) => Some(tl.font_size),
9876 _ => None,
9877 }
9878}
9879
9880fn element_font_name(element: &ContentElement) -> Option<String> {
9881 match element {
9882 ContentElement::Paragraph(p) => p.base.font_name.clone(),
9883 ContentElement::Heading(h) => h.base.base.font_name.clone(),
9884 ContentElement::NumberHeading(nh) => nh.base.base.base.font_name.clone(),
9885 _ => None,
9886 }
9887}
9888
9889fn table_border_from_element(
9890 element: &ContentElement,
9891) -> Option<&crate::models::table::TableBorder> {
9892 match element {
9893 ContentElement::TableBorder(table) => Some(table),
9894 ContentElement::Table(table) => Some(&table.table_border),
9895 _ => None,
9896 }
9897}
9898
9899fn build_geometric_table_region(
9900 doc: &PdfDocument,
9901 table_idx: usize,
9902 table: &crate::models::table::TableBorder,
9903) -> Option<GeometricTableRegion> {
9904 let mut table_rows = collect_table_border_rows(table);
9905 if table_rows.is_empty() || table.num_columns < 3 {
9906 return None;
9907 }
9908 merge_continuation_rows(&mut table_rows);
9909
9910 let column_ranges = table_column_ranges(table)?;
9911 let candidate_indices = collect_table_header_candidate_indices(doc, table_idx, table);
9912 if candidate_indices.is_empty() {
9913 return None;
9914 }
9915
9916 let needs_external_stub =
9917 infer_left_stub_requirement(doc, &candidate_indices, &table_rows, &column_ranges);
9918 let supports_embedded_stub_header =
9919 supports_embedded_stub_header(&table_rows, &column_ranges, doc, &candidate_indices);
9920 if !needs_external_stub && !supports_embedded_stub_header {
9921 return None;
9922 }
9923 let slot_ranges = if needs_external_stub {
9924 slot_ranges(&column_ranges, doc, &candidate_indices, true)?
9925 } else {
9926 column_ranges.clone()
9927 };
9928 let mut header_rows = reconstruct_aligned_rows(doc, &candidate_indices, &slot_ranges, true, 2);
9929 if header_rows.is_empty() {
9930 return None;
9931 }
9932 if needs_external_stub {
9933 normalize_leading_stub_header(&mut header_rows);
9934 } else {
9935 promote_embedded_stub_header(&mut header_rows, &table_rows);
9936 }
9937
9938 let slot_count = slot_ranges.len();
9939 let dense_header_rows = header_rows
9940 .iter()
9941 .filter(|row| {
9942 row.iter().filter(|cell| !cell.trim().is_empty()).count()
9943 >= slot_count.saturating_sub(1).max(2)
9944 })
9945 .count();
9946 if dense_header_rows == 0 {
9947 return None;
9948 }
9949
9950 let mut combined_rows = Vec::new();
9951 combined_rows.extend(header_rows);
9952
9953 let following_indices = collect_table_footer_candidate_indices(doc, table_idx, table);
9954 let body_rows = if needs_external_stub && should_merge_panel_body_rows(&table_rows) {
9955 let trailing_rows =
9956 reconstruct_aligned_rows(doc, &following_indices, &slot_ranges, false, 1);
9957 vec![merge_panel_body_row(
9958 &table_rows,
9959 &trailing_rows,
9960 slot_count,
9961 )]
9962 } else if needs_external_stub {
9963 table_rows
9964 .iter()
9965 .map(|row| {
9966 let mut shifted = vec![String::new()];
9967 shifted.extend(row.iter().cloned());
9968 shifted
9969 })
9970 .collect()
9971 } else {
9972 table_rows
9973 };
9974
9975 if body_rows.is_empty() {
9976 return None;
9977 }
9978 combined_rows.extend(body_rows);
9979
9980 let rendered = render_pipe_rows(&combined_rows);
9981 Some(GeometricTableRegion {
9982 start_idx: candidate_indices[0],
9983 end_idx: following_indices.last().copied().unwrap_or(table_idx),
9984 rendered,
9985 })
9986}
9987
9988fn table_column_ranges(table: &crate::models::table::TableBorder) -> Option<Vec<(f64, f64)>> {
9989 if table.num_columns == 0 {
9990 return None;
9991 }
9992
9993 let mut ranges = vec![(f64::INFINITY, f64::NEG_INFINITY); table.num_columns];
9994 for row in &table.rows {
9995 for cell in &row.cells {
9996 if cell.col_number >= table.num_columns {
9997 continue;
9998 }
9999 let range = &mut ranges[cell.col_number];
10000 range.0 = range.0.min(cell.bbox.left_x);
10001 range.1 = range.1.max(cell.bbox.right_x);
10002 }
10003 }
10004
10005 if ranges
10006 .iter()
10007 .any(|(left, right)| !left.is_finite() || !right.is_finite() || right <= left)
10008 {
10009 return None;
10010 }
10011
10012 Some(ranges)
10013}
10014
10015fn collect_table_header_candidate_indices(
10016 doc: &PdfDocument,
10017 table_idx: usize,
10018 table: &crate::models::table::TableBorder,
10019) -> Vec<usize> {
10020 let mut indices = Vec::new();
10021 let table_page = table.bbox.page_number;
10022 let table_top = table.bbox.top_y;
10023 let mut cursor = table_idx;
10024
10025 while let Some(prev_idx) = cursor.checked_sub(1) {
10026 let element = &doc.kids[prev_idx];
10027 if element.page_number() != table_page {
10028 break;
10029 }
10030 if !is_geometric_text_candidate(element) {
10031 break;
10032 }
10033
10034 let bbox = element.bbox();
10035 let vertical_gap = bbox.bottom_y - table_top;
10036 if !(-6.0..=260.0).contains(&vertical_gap) {
10037 break;
10038 }
10039
10040 indices.push(prev_idx);
10041 cursor = prev_idx;
10042 if indices.len() >= 10 {
10043 break;
10044 }
10045 }
10046
10047 indices.reverse();
10048 indices
10049}
10050
10051fn collect_table_footer_candidate_indices(
10052 doc: &PdfDocument,
10053 table_idx: usize,
10054 table: &crate::models::table::TableBorder,
10055) -> Vec<usize> {
10056 let mut indices = Vec::new();
10057 let table_page = table.bbox.page_number;
10058 let table_bottom = table.bbox.bottom_y;
10059
10060 for idx in table_idx + 1..doc.kids.len() {
10061 let element = &doc.kids[idx];
10062 if element.page_number() != table_page {
10063 break;
10064 }
10065 if !is_geometric_text_candidate(element) {
10066 break;
10067 }
10068 if looks_like_margin_page_number(doc, element, &extract_element_text(element)) {
10069 break;
10070 }
10071
10072 let bbox = element.bbox();
10073 let gap = table_bottom - bbox.top_y;
10074 if !(-6.0..=28.0).contains(&gap) {
10075 break;
10076 }
10077 indices.push(idx);
10078 if indices.len() >= 4 {
10079 break;
10080 }
10081 }
10082
10083 indices
10084}
10085
10086fn is_geometric_text_candidate(element: &ContentElement) -> bool {
10087 matches!(
10088 element,
10089 ContentElement::Paragraph(_)
10090 | ContentElement::Heading(_)
10091 | ContentElement::NumberHeading(_)
10092 | ContentElement::TextBlock(_)
10093 | ContentElement::TextLine(_)
10094 )
10095}
10096
10097fn infer_left_stub_requirement(
10098 doc: &PdfDocument,
10099 candidate_indices: &[usize],
10100 table_rows: &[Vec<String>],
10101 column_ranges: &[(f64, f64)],
10102) -> bool {
10103 if column_ranges.is_empty() {
10104 return false;
10105 }
10106
10107 let first_width = (column_ranges[0].1 - column_ranges[0].0).max(1.0);
10108 let has_left_label = candidate_indices.iter().any(|idx| {
10109 let bbox = doc.kids[*idx].bbox();
10110 bbox.right_x <= column_ranges[0].0 + first_width * 0.12
10111 && bbox.width() <= first_width * 0.45
10112 });
10113 if !has_left_label {
10114 return false;
10115 }
10116
10117 let mut first_col_word_counts: Vec<usize> = table_rows
10118 .iter()
10119 .filter_map(|row| row.first())
10120 .map(|cell| cell.split_whitespace().count())
10121 .collect();
10122 if first_col_word_counts.is_empty() {
10123 return false;
10124 }
10125 first_col_word_counts.sort_unstable();
10126 let median = first_col_word_counts[first_col_word_counts.len() / 2];
10127 median >= 5
10128}
10129
10130fn supports_embedded_stub_header(
10131 table_rows: &[Vec<String>],
10132 column_ranges: &[(f64, f64)],
10133 doc: &PdfDocument,
10134 candidate_indices: &[usize],
10135) -> bool {
10136 if table_rows.len() < 2 || column_ranges.len() < 3 {
10137 return false;
10138 }
10139
10140 let first_row = &table_rows[0];
10141 if first_row.len() != column_ranges.len() || first_row[0].trim().is_empty() {
10142 return false;
10143 }
10144 if first_row[0].split_whitespace().count() > 3 || first_row[0].trim().len() > 24 {
10145 return false;
10146 }
10147
10148 let data_fill = first_row
10149 .iter()
10150 .skip(1)
10151 .filter(|cell| !cell.trim().is_empty())
10152 .count();
10153 if data_fill + 1 < column_ranges.len() {
10154 return false;
10155 }
10156
10157 let labeled_rows = table_rows
10158 .iter()
10159 .skip(1)
10160 .filter(|row| row.first().is_some_and(|cell| !cell.trim().is_empty()))
10161 .count();
10162 if labeled_rows == 0 {
10163 return false;
10164 }
10165
10166 let slot_ranges = column_ranges.to_vec();
10167 let header_rows = reconstruct_aligned_rows(doc, candidate_indices, &slot_ranges, true, 2);
10168 header_rows.iter().any(|row| {
10169 row.first().is_none_or(|cell| cell.trim().is_empty())
10170 && row
10171 .iter()
10172 .skip(1)
10173 .filter(|cell| !cell.trim().is_empty())
10174 .count()
10175 >= column_ranges.len().saturating_sub(1)
10176 })
10177}
10178
10179fn slot_ranges(
10180 column_ranges: &[(f64, f64)],
10181 doc: &PdfDocument,
10182 candidate_indices: &[usize],
10183 needs_stub: bool,
10184) -> Option<Vec<(f64, f64)>> {
10185 let mut slots = Vec::new();
10186 if needs_stub {
10187 let first_left = column_ranges.first()?.0;
10188 let left_stub_start = candidate_indices
10189 .iter()
10190 .map(|idx| doc.kids[*idx].bbox().left_x)
10191 .fold(first_left, f64::min);
10192 let stub_right = first_left - 1.0;
10193 if stub_right <= left_stub_start {
10194 return None;
10195 }
10196 slots.push((left_stub_start, stub_right));
10197 }
10198 slots.extend(column_ranges.iter().copied());
10199 Some(slots)
10200}
10201
10202fn reconstruct_aligned_rows(
10203 doc: &PdfDocument,
10204 candidate_indices: &[usize],
10205 slot_ranges: &[(f64, f64)],
10206 drop_wide_singletons: bool,
10207 min_filled_slots: usize,
10208) -> Vec<Vec<String>> {
10209 if candidate_indices.is_empty() || slot_ranges.is_empty() {
10210 return Vec::new();
10211 }
10212
10213 let mut row_bands: Vec<(BoundingBox, Vec<String>)> = Vec::new();
10214
10215 for idx in candidate_indices {
10216 for line in extract_chunk_lines(&doc.kids[*idx]) {
10217 let fragments = split_line_into_slot_fragments(&line, slot_ranges);
10218 if fragments.is_empty() {
10219 continue;
10220 }
10221
10222 if drop_wide_singletons && fragments.len() == 1 {
10223 let only = &fragments[0];
10224 let span_width = only.bbox.width();
10225 let table_width =
10226 slot_ranges.last().map(|(_, right)| *right).unwrap_or(0.0) - slot_ranges[0].0;
10227 if span_width >= table_width * 0.55 {
10228 continue;
10229 }
10230 }
10231
10232 let line_center = line.bbox.center_y();
10233 let tolerance = line
10234 .chunks
10235 .iter()
10236 .map(|chunk| chunk.font_size)
10237 .fold(8.0, f64::max)
10238 * 0.8;
10239
10240 let mut target_row = None;
10241 for (row_idx, (bbox, _)) in row_bands.iter().enumerate() {
10242 if (bbox.center_y() - line_center).abs() <= tolerance {
10243 target_row = Some(row_idx);
10244 break;
10245 }
10246 }
10247
10248 if let Some(row_idx) = target_row {
10249 let (bbox, cells) = &mut row_bands[row_idx];
10250 *bbox = bbox.union(&line.bbox);
10251 for fragment in fragments {
10252 append_cell_text(&mut cells[fragment.slot_idx], &fragment.text);
10253 }
10254 } else {
10255 let mut cells = vec![String::new(); slot_ranges.len()];
10256 for fragment in fragments {
10257 append_cell_text(&mut cells[fragment.slot_idx], &fragment.text);
10258 }
10259 row_bands.push((line.bbox.clone(), cells));
10260 }
10261 }
10262 }
10263
10264 row_bands.sort_by(|left, right| {
10265 right
10266 .0
10267 .top_y
10268 .partial_cmp(&left.0.top_y)
10269 .unwrap_or(std::cmp::Ordering::Equal)
10270 });
10271
10272 row_bands
10273 .into_iter()
10274 .map(|(_, cells)| cells)
10275 .filter(|cells| {
10276 let filled = cells.iter().filter(|cell| !cell.trim().is_empty()).count();
10277 filled >= min_filled_slots
10278 })
10279 .collect()
10280}
10281
10282fn extract_chunk_lines(element: &ContentElement) -> Vec<ChunkLine> {
10283 match element {
10284 ContentElement::Paragraph(p) => chunk_lines_from_semantic_node(&p.base),
10285 ContentElement::Heading(h) => chunk_lines_from_semantic_node(&h.base.base),
10286 ContentElement::NumberHeading(nh) => chunk_lines_from_semantic_node(&nh.base.base.base),
10287 ContentElement::TextBlock(tb) => tb
10288 .text_lines
10289 .iter()
10290 .map(|line| ChunkLine {
10291 bbox: line.bbox.clone(),
10292 chunks: line.text_chunks.clone(),
10293 })
10294 .collect(),
10295 ContentElement::TextLine(tl) => vec![ChunkLine {
10296 bbox: tl.bbox.clone(),
10297 chunks: tl.text_chunks.clone(),
10298 }],
10299 _ => Vec::new(),
10300 }
10301}
10302
10303fn chunk_lines_from_semantic_node(node: &SemanticTextNode) -> Vec<ChunkLine> {
10304 let mut lines = Vec::new();
10305 for column in &node.columns {
10306 for block in &column.text_blocks {
10307 for line in &block.text_lines {
10308 lines.push(ChunkLine {
10309 bbox: line.bbox.clone(),
10310 chunks: line.text_chunks.clone(),
10311 });
10312 }
10313 }
10314 }
10315 lines
10316}
10317
10318fn split_line_into_slot_fragments(
10319 line: &ChunkLine,
10320 slot_ranges: &[(f64, f64)],
10321) -> Vec<SlotFragment> {
10322 let mut groups: Vec<(usize, Vec<TextChunk>, BoundingBox)> = Vec::new();
10323
10324 for chunk in line
10325 .chunks
10326 .iter()
10327 .filter(|chunk| !chunk.value.trim().is_empty())
10328 .cloned()
10329 {
10330 let slot_idx = assign_chunk_to_slot(&chunk.bbox, slot_ranges);
10331 if let Some((prev_slot, prev_chunks, prev_bbox)) = groups.last_mut() {
10332 let gap = chunk.bbox.left_x - prev_bbox.right_x;
10333 if *prev_slot == slot_idx && gap <= chunk.font_size.max(6.0) * 2.4 {
10334 *prev_bbox = prev_bbox.union(&chunk.bbox);
10335 prev_chunks.push(chunk);
10336 continue;
10337 }
10338 }
10339 groups.push((slot_idx, vec![chunk.clone()], chunk.bbox.clone()));
10340 }
10341
10342 groups
10343 .into_iter()
10344 .filter_map(|(slot_idx, chunks, bbox)| {
10345 let text = normalize_common_ocr_text(
10346 &crate::models::text::TextLine::concatenate_chunks(&chunks),
10347 );
10348 if text.trim().is_empty() {
10349 None
10350 } else {
10351 Some(SlotFragment {
10352 slot_idx,
10353 bbox,
10354 text,
10355 })
10356 }
10357 })
10358 .collect()
10359}
10360
10361fn assign_chunk_to_slot(bbox: &BoundingBox, slot_ranges: &[(f64, f64)]) -> usize {
10362 let mut best_idx = 0usize;
10363 let mut best_overlap = f64::NEG_INFINITY;
10364 let center_x = bbox.center_x();
10365
10366 for (idx, (left, right)) in slot_ranges.iter().enumerate() {
10367 let overlap = (bbox.right_x.min(*right) - bbox.left_x.max(*left)).max(0.0);
10368 let score = if overlap > 0.0 {
10369 overlap / bbox.width().max(1.0)
10370 } else {
10371 -((center_x - ((*left + *right) / 2.0)).abs())
10372 };
10373 if score > best_overlap {
10374 best_overlap = score;
10375 best_idx = idx;
10376 }
10377 }
10378
10379 best_idx
10380}
10381
10382fn append_cell_text(cell: &mut String, fragment: &str) {
10383 let trimmed = fragment.trim();
10384 if trimmed.is_empty() {
10385 return;
10386 }
10387 if !cell.is_empty() {
10388 cell.push(' ');
10389 }
10390 cell.push_str(trimmed);
10391}
10392
10393fn normalize_leading_stub_header(rows: &mut [Vec<String>]) {
10394 if rows.len() < 2 || rows[0].is_empty() || rows[1].is_empty() {
10395 return;
10396 }
10397
10398 if !rows[0][0].trim().is_empty() || rows[1][0].trim().is_empty() {
10399 return;
10400 }
10401
10402 let first_row_filled = rows[0]
10403 .iter()
10404 .skip(1)
10405 .filter(|cell| !cell.trim().is_empty())
10406 .count();
10407 let second_row_filled = rows[1]
10408 .iter()
10409 .skip(1)
10410 .filter(|cell| !cell.trim().is_empty())
10411 .count();
10412 if first_row_filled < 2 || second_row_filled < 2 {
10413 return;
10414 }
10415
10416 rows[0][0] = rows[1][0].trim().to_string();
10417 rows[1][0].clear();
10418}
10419
10420fn promote_embedded_stub_header(header_rows: &mut [Vec<String>], table_rows: &[Vec<String>]) {
10421 let Some(header_row) = header_rows.first_mut() else {
10422 return;
10423 };
10424 let Some(first_body_row) = table_rows.first() else {
10425 return;
10426 };
10427 if header_row.is_empty() || first_body_row.is_empty() {
10428 return;
10429 }
10430 if !header_row[0].trim().is_empty() {
10431 return;
10432 }
10433
10434 let promoted = first_body_row[0].trim();
10435 if promoted.is_empty() || promoted.split_whitespace().count() > 3 || promoted.len() > 24 {
10436 return;
10437 }
10438
10439 let header_fill = header_row
10440 .iter()
10441 .skip(1)
10442 .filter(|cell| !cell.trim().is_empty())
10443 .count();
10444 let body_fill = first_body_row
10445 .iter()
10446 .skip(1)
10447 .filter(|cell| !cell.trim().is_empty())
10448 .count();
10449 if header_fill < header_row.len().saturating_sub(1)
10450 || body_fill < first_body_row.len().saturating_sub(1)
10451 {
10452 return;
10453 }
10454
10455 header_row[0] = promoted.to_string();
10456}
10457
10458fn should_merge_panel_body_rows(rows: &[Vec<String>]) -> bool {
10459 rows.len() >= 3
10460 && rows
10461 .iter()
10462 .all(|row| !row.is_empty() && row.iter().all(|cell| !cell.trim().is_empty()))
10463}
10464
10465fn merge_panel_body_row(
10466 table_rows: &[Vec<String>],
10467 trailing_rows: &[Vec<String>],
10468 slot_count: usize,
10469) -> Vec<String> {
10470 let mut merged = vec![String::new(); slot_count];
10471 for row in table_rows {
10472 for (col_idx, cell) in row.iter().enumerate() {
10473 if col_idx + 1 >= slot_count {
10474 break;
10475 }
10476 append_cell_text(&mut merged[col_idx + 1], cell);
10477 }
10478 }
10479 for row in trailing_rows {
10480 for (col_idx, cell) in row.iter().enumerate() {
10481 if col_idx >= slot_count {
10482 break;
10483 }
10484 append_cell_text(&mut merged[col_idx], cell);
10485 }
10486 }
10487 merged
10488}
10489
10490fn render_pipe_rows(rows: &[Vec<String>]) -> String {
10491 if rows.is_empty() {
10492 return String::new();
10493 }
10494
10495 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
10496 if num_cols == 0 {
10497 return String::new();
10498 }
10499
10500 let mut out = String::new();
10501 for (row_idx, row) in rows.iter().enumerate() {
10502 out.push('|');
10503 for col_idx in 0..num_cols {
10504 let cell = row.get(col_idx).map(String::as_str).unwrap_or("");
10505 out.push_str(&format!(" {} |", cell.trim()));
10506 }
10507 out.push('\n');
10508
10509 if row_idx == 0 {
10510 out.push('|');
10511 for _ in 0..num_cols {
10512 out.push_str(" --- |");
10513 }
10514 out.push('\n');
10515 }
10516 }
10517 out.push('\n');
10518 out
10519}
10520
10521fn render_html_table(rows: &[Vec<String>]) -> String {
10522 if rows.is_empty() {
10523 return String::new();
10524 }
10525
10526 let num_cols = rows.iter().map(Vec::len).max().unwrap_or(0);
10527 if num_cols == 0 {
10528 return String::new();
10529 }
10530
10531 let mut out = String::from("<table>\n");
10532 for (row_idx, row) in rows.iter().enumerate() {
10533 out.push_str("<tr>");
10534 for col_idx in 0..num_cols {
10535 let cell = escape_html_text(row.get(col_idx).map(String::as_str).unwrap_or("").trim());
10536 if row_idx == 0 {
10537 out.push_str("<th>");
10538 out.push_str(&cell);
10539 out.push_str("</th>");
10540 } else {
10541 out.push_str("<td>");
10542 out.push_str(&cell);
10543 out.push_str("</td>");
10544 }
10545 }
10546 out.push_str("</tr>\n");
10547 }
10548 out.push_str("</table>\n\n");
10549 out
10550}
10551
10552fn escape_html_text(text: &str) -> String {
10553 text.replace('&', "&")
10554 .replace('<', "<")
10555 .replace('>', ">")
10556 .replace('"', """)
10557 .replace('\'', "'")
10558}
10559
10560fn normalized_numeric_marker(text: &str) -> Option<String> {
10561 let digits = text
10562 .chars()
10563 .filter(|ch| ch.is_ascii_digit())
10564 .collect::<String>();
10565 (!digits.is_empty() && digits.len() <= 2).then_some(digits)
10566}
10567
10568fn render_infographic_card_rows(rows: &[Vec<String>]) -> Option<String> {
10569 if rows.is_empty() || !rows.iter().all(|row| row.len() == 2) {
10570 return None;
10571 }
10572
10573 let marker = normalized_numeric_marker(rows[0][0].trim())?;
10574 if rows[0][1].split_whitespace().count() < 4 {
10575 return None;
10576 }
10577 if rows
10578 .iter()
10579 .skip(1)
10580 .any(|row| normalized_numeric_marker(row[0].trim()).is_some())
10581 {
10582 return None;
10583 }
10584 if rows
10585 .iter()
10586 .skip(1)
10587 .any(|row| !row[0].trim().is_empty() && row[0].trim().len() > 2)
10588 {
10589 return None;
10590 }
10591
10592 let body = rows
10593 .iter()
10594 .filter_map(|row| row.get(1))
10595 .map(|cell| cell.trim())
10596 .filter(|cell| !cell.is_empty())
10597 .collect::<Vec<_>>()
10598 .join(" ");
10599 if body.split_whitespace().count() < 8 {
10600 return None;
10601 }
10602
10603 Some(format!("{marker}. {body}\n\n"))
10604}
10605
10606fn extract_element_text(element: &ContentElement) -> String {
10607 match element {
10608 ContentElement::Paragraph(p) => clean_paragraph_text(&p.base.value()),
10609 ContentElement::Heading(h) => clean_paragraph_text(&h.base.base.value()),
10610 ContentElement::NumberHeading(nh) => clean_paragraph_text(&nh.base.base.base.value()),
10611 ContentElement::TextBlock(tb) => clean_paragraph_text(&tb.value()),
10612 ContentElement::TextLine(tl) => clean_paragraph_text(&tl.value()),
10613 _ => String::new(),
10614 }
10615}
10616
10617fn collect_table_border_rows(table: &crate::models::table::TableBorder) -> Vec<Vec<String>> {
10619 let num_cols = table.num_columns.max(1);
10620 let mut rendered_rows: Vec<Vec<String>> = Vec::new();
10621 for row in &table.rows {
10622 let cell_texts: Vec<String> = (0..num_cols)
10623 .map(|col| {
10624 row.cells
10625 .iter()
10626 .find(|c| c.col_number == col)
10627 .map(cell_text_content)
10628 .unwrap_or_default()
10629 })
10630 .collect();
10631 if !cell_texts.iter().all(|t| t.trim().is_empty()) {
10632 rendered_rows.push(cell_texts);
10633 }
10634 }
10635 rendered_rows
10636}
10637
10638fn render_table_border(out: &mut String, table: &crate::models::table::TableBorder) {
10644 if table.rows.is_empty() {
10645 return;
10646 }
10647
10648 if table_has_merged_cells(table) {
10649 out.push_str(&render_table_border_html(table));
10650 return;
10651 }
10652
10653 let mut rendered_rows = collect_table_border_rows(table);
10655
10656 if rendered_rows.is_empty() {
10657 return;
10658 }
10659
10660 if let Some(rendered) = render_infographic_card_rows(&rendered_rows) {
10661 out.push_str(&rendered);
10662 return;
10663 }
10664
10665 merge_continuation_rows(&mut rendered_rows);
10667 trim_leading_table_carryover_rows(&mut rendered_rows);
10668
10669 if is_toc_table(&rendered_rows) {
10671 render_toc_rows(out, &rendered_rows);
10672 return;
10673 }
10674
10675 out.push_str(&render_pipe_rows(&rendered_rows));
10676}
10677
10678fn table_has_merged_cells(table: &crate::models::table::TableBorder) -> bool {
10679 table
10680 .rows
10681 .iter()
10682 .flat_map(|row| row.cells.iter())
10683 .any(|cell| cell.row_span > 1 || cell.col_span > 1)
10684}
10685
10686fn render_table_border_html(table: &crate::models::table::TableBorder) -> String {
10688 let mut out = String::from("<table>\n");
10689 for row in &table.rows {
10690 out.push_str(" <tr>\n");
10691 let mut cells: Vec<_> = row.cells.iter().collect();
10693 cells.sort_by_key(|c| c.col_number);
10694 for cell in cells {
10695 if cell.row_span == 0 || cell.col_span == 0 {
10696 continue;
10697 }
10698 let text = escape_html_text(cell_text_content(cell).trim());
10699 let tag = if row.row_number == 0 { "th" } else { "td" };
10700 out.push_str(" <");
10701 out.push_str(tag);
10702 if cell.col_span > 1 {
10703 out.push_str(&format!(" colspan=\"{}\"", cell.col_span));
10704 }
10705 if cell.row_span > 1 {
10706 out.push_str(&format!(" rowspan=\"{}\"", cell.row_span));
10707 }
10708 out.push('>');
10709 out.push_str(&text);
10710 out.push_str("</");
10711 out.push_str(tag);
10712 out.push_str(">\n");
10713 }
10714 out.push_str(" </tr>\n");
10715 }
10716 out.push_str("</table>\n\n");
10717 out
10718}
10719
10720fn is_page_number_like(text: &str) -> bool {
10722 let t = text.trim();
10723 if t.is_empty() {
10724 return false;
10725 }
10726 if t.len() <= 5 && t.chars().all(|c| c.is_ascii_digit()) {
10728 return true;
10729 }
10730 let lower = t.to_ascii_lowercase();
10732 if lower.len() <= 10 && lower.chars().all(|c| "ivxlcdm".contains(c)) {
10733 return true;
10734 }
10735 false
10736}
10737
10738fn is_toc_table(rows: &[Vec<String>]) -> bool {
10741 if rows.is_empty() {
10742 return false;
10743 }
10744 if rows.len() < 2 {
10746 return false;
10747 }
10748 if !rows.iter().all(|r| r.len() == 2) {
10750 return false;
10751 }
10752
10753 let non_empty_right = rows.iter().filter(|r| !r[1].trim().is_empty()).count();
10754 if non_empty_right < 2 {
10755 return false;
10756 }
10757
10758 let page_like = rows.iter().filter(|r| is_page_number_like(&r[1])).count();
10759 page_like >= 2 && page_like * 10 >= non_empty_right * 9 && page_like * 2 >= rows.len()
10760}
10761
10762fn render_toc_rows(out: &mut String, rows: &[Vec<String>]) {
10764 for row in rows {
10765 let title = row[0].trim();
10766 let page = row[1].trim();
10767 if title.is_empty() && page.is_empty() {
10768 continue;
10769 }
10770 if !title.is_empty() && !page.is_empty() {
10771 out.push_str(title);
10772 out.push(' ');
10773 out.push_str(page);
10774 } else {
10775 out.push_str(title);
10776 out.push_str(page);
10777 }
10778 out.push('\n');
10779 }
10780 out.push('\n');
10781}
10782
10783fn cell_text_content(cell: &crate::models::table::TableBorderCell) -> String {
10785 if !cell.content.is_empty() {
10789 let chunks: Vec<_> = cell.content.iter().map(|t| t.base.clone()).collect();
10790 return normalize_common_ocr_text(&crate::models::text::TextLine::concatenate_chunks(
10791 &chunks,
10792 ));
10793 }
10794 let mut text = String::new();
10796 for elem in &cell.contents {
10797 match elem {
10798 ContentElement::Paragraph(p) => text.push_str(&p.base.value()),
10799 ContentElement::TextBlock(tb) => text.push_str(&tb.value()),
10800 ContentElement::TextLine(tl) => text.push_str(&tl.value()),
10801 ContentElement::TextChunk(tc) => text.push_str(&tc.value),
10802 _ => {}
10803 }
10804 }
10805 normalize_common_ocr_text(&repair_fragmented_words(&text))
10806}
10807
10808fn merge_adjacent_pipe_tables(markdown: &str) -> String {
10816 let lines: Vec<&str> = markdown.lines().collect();
10817 if lines.len() < 4 {
10818 return markdown.to_string();
10819 }
10820
10821 fn count_pipe_cols(line: &str) -> usize {
10822 let t = line.trim();
10823 if !t.starts_with('|') || !t.ends_with('|') {
10824 return 0;
10825 }
10826 t.split('|').count().saturating_sub(2)
10827 }
10828
10829 fn is_separator(line: &str) -> bool {
10830 let t = line.trim();
10831 if !t.starts_with('|') || !t.ends_with('|') {
10832 return false;
10833 }
10834 let cells: Vec<&str> = t.split('|').collect();
10835 if cells.len() < 3 {
10836 return false;
10837 }
10838 cells[1..cells.len() - 1].iter().all(|c| {
10839 let s = c.trim();
10840 !s.is_empty() && s.chars().all(|ch| ch == '-' || ch == ':')
10841 })
10842 }
10843
10844 fn is_pipe_row(line: &str) -> bool {
10845 let t = line.trim();
10846 t.starts_with('|') && t.ends_with('|') && t.len() > 2
10847 }
10848
10849 fn pipe_cells(line: &str) -> Vec<String> {
10850 let t = line.trim();
10851 if !is_pipe_row(t) {
10852 return Vec::new();
10853 }
10854 let parts = t.split('|').collect::<Vec<_>>();
10855 parts[1..parts.len() - 1]
10856 .iter()
10857 .map(|cell| cell.trim().to_string())
10858 .collect()
10859 }
10860
10861 fn normalize_header_cell(cell: &str) -> String {
10862 cell.chars()
10863 .filter(|ch| ch.is_alphanumeric())
10864 .flat_map(|ch| ch.to_lowercase())
10865 .collect()
10866 }
10867
10868 fn looks_like_header_row(line: &str) -> bool {
10869 let cells = pipe_cells(line);
10870 if cells.len() < 2 {
10871 return false;
10872 }
10873
10874 let non_empty = cells
10875 .iter()
10876 .filter(|cell| !cell.trim().is_empty())
10877 .collect::<Vec<_>>();
10878 if non_empty.len() < 2 {
10879 return false;
10880 }
10881
10882 let headerish = non_empty.iter().all(|cell| {
10883 let trimmed = cell.trim();
10884 let word_count = trimmed.split_whitespace().count();
10885 let has_alpha = trimmed.chars().any(|ch| ch.is_alphabetic());
10886 has_alpha && word_count <= 4 && trimmed.len() <= 28
10887 });
10888 headerish
10889 }
10890
10891 fn header_overlap_ratio(left: &str, right: &str) -> f64 {
10892 let left_cells = pipe_cells(left)
10893 .into_iter()
10894 .map(|cell| normalize_header_cell(&cell))
10895 .collect::<Vec<_>>();
10896 let right_cells = pipe_cells(right)
10897 .into_iter()
10898 .map(|cell| normalize_header_cell(&cell))
10899 .collect::<Vec<_>>();
10900 let width = left_cells.len().min(right_cells.len());
10901 if width == 0 {
10902 return 0.0;
10903 }
10904
10905 let matches = (0..width)
10906 .filter(|idx| {
10907 !left_cells[*idx].is_empty()
10908 && !right_cells[*idx].is_empty()
10909 && left_cells[*idx] == right_cells[*idx]
10910 })
10911 .count();
10912 matches as f64 / width as f64
10913 }
10914
10915 fn header_schema_matches(left: &str, right: &str) -> bool {
10916 let left_cells = pipe_cells(left)
10917 .into_iter()
10918 .map(|cell| normalize_header_cell(&cell))
10919 .collect::<Vec<_>>();
10920 let right_cells = pipe_cells(right)
10921 .into_iter()
10922 .map(|cell| normalize_header_cell(&cell))
10923 .collect::<Vec<_>>();
10924 if left_cells.len() != right_cells.len() || left_cells.len() < 2 {
10925 return false;
10926 }
10927
10928 let mut aligned_non_empty = 0usize;
10929 for (left, right) in left_cells.iter().zip(right_cells.iter()) {
10930 if left.is_empty() || right.is_empty() {
10931 continue;
10932 }
10933 aligned_non_empty += 1;
10934 if left != right {
10935 return false;
10936 }
10937 }
10938
10939 aligned_non_empty >= 2
10940 }
10941
10942 fn pad_pipe_row(line: &str, target_cols: usize) -> String {
10943 let t = line.trim();
10944 let current_cols = count_pipe_cols(t);
10945 if current_cols >= target_cols {
10946 return t.to_string();
10947 }
10948 let mut result = t.to_string();
10950 for _ in current_cols..target_cols {
10951 result.push_str(" |");
10952 }
10953 result
10954 }
10955
10956 struct Block {
10958 start: usize,
10959 sep: usize,
10960 end: usize, cols: usize,
10962 }
10963
10964 let mut blocks: Vec<Block> = Vec::new();
10965 let mut i = 0;
10966 while i < lines.len() {
10967 if i + 1 < lines.len() && is_pipe_row(lines[i]) && is_separator(lines[i + 1]) {
10968 let cols = count_pipe_cols(lines[i]);
10969 let sep = i + 1;
10970 let mut end = sep;
10971 let mut j = sep + 1;
10972 while j < lines.len() && is_pipe_row(lines[j]) && !is_separator(lines[j]) {
10973 end = j;
10974 j += 1;
10975 }
10976 blocks.push(Block {
10977 start: i,
10978 sep,
10979 end,
10980 cols,
10981 });
10982 i = end + 1;
10983 } else {
10984 i += 1;
10985 }
10986 }
10987
10988 if blocks.len() < 2 {
10989 return markdown.to_string();
10990 }
10991
10992 let mut merge_leader: Vec<Option<usize>> = vec![None; blocks.len()];
10998 let mut group_cols: Vec<usize> = blocks.iter().map(|b| b.cols).collect();
10999 for bi in 1..blocks.len() {
11000 let prev = &blocks[bi - 1];
11001 let curr = &blocks[bi];
11002 let gap_range = prev.end + 1..curr.start;
11003 let gap_all_blank = gap_range.clone().all(|li| lines[li].trim().is_empty());
11004 let leader_idx = merge_leader[bi - 1].unwrap_or(bi - 1);
11008 let effective_prev_cols = group_cols[leader_idx];
11009 let gap_heading_only = if !gap_all_blank && effective_prev_cols >= 2 && curr.cols >= 2 {
11010 let non_blank: Vec<usize> = gap_range
11011 .clone()
11012 .filter(|li| !lines[*li].trim().is_empty())
11013 .collect();
11014 !non_blank.is_empty()
11016 && non_blank.len() <= 2
11017 && non_blank.iter().all(|li| {
11018 let t = lines[*li].trim();
11019 t.starts_with('#') && t.len() < 100
11020 })
11021 } else {
11022 false
11023 };
11024 let gap_short_fragment =
11028 if !gap_all_blank && !gap_heading_only && effective_prev_cols >= 2 && curr.cols >= 2 {
11029 let non_blank: Vec<usize> = gap_range
11030 .clone()
11031 .filter(|li| !lines[*li].trim().is_empty())
11032 .collect();
11033 non_blank.len() == 1 && {
11034 let t = lines[non_blank[0]].trim();
11035 t.len() < 30
11036 && !t.starts_with('#')
11037 && !t.starts_with('-')
11038 && !t.starts_with('*')
11039 && !t.contains(':')
11040 && !t.contains("TABLE")
11041 }
11042 } else {
11043 false
11044 };
11045 let prev_has_header = looks_like_header_row(lines[prev.start]);
11046 let curr_has_header = curr.end >= curr.sep + 2 && looks_like_header_row(lines[curr.start]);
11047 let curr_has_distinct_header = prev_has_header
11048 && curr_has_header
11049 && !header_schema_matches(lines[prev.start], lines[curr.start])
11050 && (curr.cols != prev.cols
11051 || header_overlap_ratio(lines[prev.start], lines[curr.start]) < 1.0);
11052
11053 if (gap_all_blank || gap_heading_only || gap_short_fragment)
11054 && prev.cols > 0
11055 && curr.cols > 0
11056 && !curr_has_distinct_header
11057 {
11058 merge_leader[bi] = Some(leader_idx);
11059 if curr.cols > group_cols[leader_idx] {
11061 group_cols[leader_idx] = curr.cols;
11062 }
11063 }
11064 }
11065
11066 let mut pad_target: Vec<usize> = vec![0; blocks.len()];
11067 for bi in 0..blocks.len() {
11068 let leader = merge_leader[bi].unwrap_or(bi);
11069 pad_target[bi] = group_cols[leader];
11070 }
11071
11072 let mut skip = vec![false; lines.len()];
11076 let mut convert_to_pipe_row = vec![false; lines.len()];
11077 for (bi, leader) in merge_leader.iter().enumerate() {
11078 if leader.is_none() {
11079 continue;
11080 }
11081 let prev_end = blocks[bi - 1].end;
11082 let curr = &blocks[bi];
11083 for li in (prev_end + 1)..curr.start {
11084 if lines[li].trim().is_empty() {
11085 skip[li] = true;
11086 } else {
11087 convert_to_pipe_row[li] = true;
11089 }
11090 }
11091 skip[curr.sep] = true;
11093 }
11094
11095 let mut line_to_block: Vec<Option<usize>> = vec![None; lines.len()];
11097 for (bi, block) in blocks.iter().enumerate() {
11098 line_to_block[block.start..=block.end].fill(Some(bi));
11099 }
11100 for (bi, leader) in merge_leader.iter().enumerate() {
11102 if leader.is_none() {
11103 continue;
11104 }
11105 let prev_end = blocks[bi - 1].end;
11106 let curr = &blocks[bi];
11107 for li in (prev_end + 1)..curr.start {
11108 if convert_to_pipe_row[li] {
11109 line_to_block[li] = Some(bi - 1);
11110 }
11111 }
11112 }
11113
11114 let mut result = String::new();
11115 for (li, line) in lines.iter().enumerate() {
11116 if skip[li] {
11117 continue;
11118 }
11119 if convert_to_pipe_row[li] {
11120 let text = line.trim().trim_start_matches('#').trim();
11122 if let Some(bi) = line_to_block[li] {
11123 let target = pad_target[bi];
11124 if target > 0 && !text.is_empty() {
11125 result.push_str(&format!("| {} ", text));
11126 for _ in 1..target {
11127 result.push_str("| ");
11128 }
11129 result.push_str("|\n");
11130 continue;
11131 }
11132 }
11133 result.push_str(line);
11135 result.push('\n');
11136 continue;
11137 }
11138 if let Some(bi) = line_to_block[li] {
11139 let target = pad_target[bi];
11140 if target > 0 && is_pipe_row(line) && !is_separator(line) {
11141 result.push_str(&pad_pipe_row(line, target));
11142 result.push('\n');
11143 } else if target > 0 && is_separator(line) {
11144 result.push('|');
11145 for _ in 0..target {
11146 result.push_str(" --- |");
11147 }
11148 result.push('\n');
11149 } else {
11150 result.push_str(line);
11151 result.push('\n');
11152 }
11153 } else {
11154 result.push_str(line);
11155 result.push('\n');
11156 }
11157 }
11158
11159 result
11160}
11161
11162#[cfg(test)]
11163mod tests {
11164 use super::*;
11165 use crate::models::bbox::BoundingBox;
11166 use crate::models::chunks::TextChunk;
11167 use crate::models::content::ContentElement;
11168 use crate::models::enums::{PdfLayer, TextFormat, TextType};
11169 use crate::models::list::{ListBody, ListItem, ListLabel, PDFList};
11170 use crate::models::semantic::{SemanticHeading, SemanticParagraph, SemanticTextNode};
11171 use crate::models::table::{
11172 TableBorder, TableBorderCell, TableBorderRow, TableToken, TableTokenType,
11173 };
11174 use crate::models::text::{TextBlock, TextColumn, TextLine};
11175
11176 #[test]
11177 fn test_empty_doc() {
11178 let doc = PdfDocument::new("test.pdf".to_string());
11179 let md = to_markdown(&doc).unwrap();
11180 assert!(md.contains("No content extracted"));
11181 }
11182
11183 #[test]
11184 fn test_with_title() {
11185 let mut doc = PdfDocument::new("test.pdf".to_string());
11186 doc.title = Some("My Title".to_string());
11187 let md = to_markdown(&doc).unwrap();
11188 assert!(md.starts_with("# My Title\n"));
11189 }
11190
11191 #[test]
11192 fn test_empty_title_not_rendered() {
11193 let mut doc = PdfDocument::new("test.pdf".to_string());
11194 doc.title = Some(" ".to_string());
11195 let md = to_markdown(&doc).unwrap();
11196 assert!(
11197 !md.contains("# "),
11198 "Empty/whitespace title should not produce a heading"
11199 );
11200 }
11201
11202 #[test]
11203 fn test_repair_fragmented_words() {
11204 assert_eq!(
11205 repair_fragmented_words("Jurisdic tion Fore ign Req uire me nts"),
11206 "Jurisdiction Foreign Requirements"
11207 );
11208 }
11209
11210 #[test]
11211 fn test_normalize_common_ocr_text_repairs_units() {
11212 assert_eq!(
11213 normalize_common_ocr_text("10 ߤL at 37 C and -20 oC"),
11214 "10 μL at 37°C and -20°C"
11215 );
11216 }
11217
11218 #[cfg(not(target_arch = "wasm32"))]
11219 #[test]
11220 fn test_build_layout_anchor_rows_reconstructs_four_column_matrix() {
11221 let lines = vec![
11222 "Key Functions by Main Service Flow".to_string(),
11223 "".to_string(),
11224 " Service Stage Function Name Explanation Expected Benefit".to_string(),
11225 "".to_string(),
11226 " 1. Project creation Project creation and Select document type to automatically run project creation, Pipeline configuration with The intuitive UI environment allows the the person in charge to quickly proceed with".to_string(),
11227 "".to_string(),
11228 " management recommended Modelset and Endpoint deployment the entire process from project creation to deployment, improving work efficiency".to_string(),
11229 "".to_string(),
11230 " Conveniently manage raw data to be used for OCR Pack and actual date from live".to_string(),
11231 " 2. Data labeling and Data storage management Provides convenient functions for uploading raw data, viewer, and data management".to_string(),
11232 " (search using image metadata, sorting, filtering, hashtags settings on image data) service".to_string(),
11233 " fine-tuning".to_string(),
11234 " Image data bookmark for Qualitative Evaluation".to_string(),
11235 "".to_string(),
11236 " Create and manage Labeling Creating a Labeling Space to manage raw data annotation, managing labeling resources Labeling work can be outsourced within the pack. Labeled data is continuously".to_string(),
11237 " (Ontology, Characters to be Recognized), data set dump, data set version management supplied from which data sets can be created with ease. The Auto Labeling function".to_string(),
11238 " Space".to_string(),
11239 " 3 increases both efficiency and convenience.".to_string(),
11240 " Various basic models for each selected 5".to_string(),
11241 " document, information comparison between".to_string(),
11242 " Model training Providing a foundation for customers to implement, manage, and upgrade their own".to_string(),
11243 " models, basic model training, training pause function, re-training, cancel function, and OCR model specialized to the customers’ needs".to_string(),
11244 " configuration support for Characters to be Recognized and Ontology that is frequently".to_string(),
11245 " modified while developing specialized models".to_string(),
11246 ];
11247
11248 let header = find_layout_header_candidate(&lines).unwrap();
11249 let rows =
11250 build_layout_anchor_rows(&lines, &extract_layout_entries(&lines, &header)).unwrap();
11251
11252 assert_eq!(
11253 header.headers,
11254 vec![
11255 "Service Stage".to_string(),
11256 "Function Name".to_string(),
11257 "Explanation".to_string(),
11258 "Expected Benefit".to_string()
11259 ]
11260 );
11261 assert_eq!(rows.len(), 4);
11262 assert_eq!(rows[0][0], "1. Project creation");
11263 assert_eq!(rows[0][1], "Project creation and management");
11264 assert!(rows[1][0].contains("fine-tuning"));
11265 assert_eq!(rows[2][1], "Create and manage Labeling Space");
11266 assert_eq!(rows[3][1], "Model training");
11267 assert!(rows[3][2].contains("Various basic models for each selected document"));
11268 }
11269
11270 #[cfg(not(target_arch = "wasm32"))]
11271 #[test]
11272 fn test_build_layout_panel_stub_rows_reconstructs_left_stub_table() {
11273 let lines = vec![
11274 "AI Pack".to_string(),
11275 "Upstage offers 3 AI packs that process unstructured information and data".to_string(),
11276 "".to_string(),
11277 " OCR Recommendation Product semantic search".to_string(),
11278 "".to_string(),
11279 " A solution that recognizes characters in an A solution that recommends the best products and A solution that enables semantic search, analyzes and".to_string(),
11280 " image and extracts necessary information contents organizes key information in unstructured text data".to_string(),
11281 " Pack".to_string(),
11282 " into a standardized form (DB)".to_string(),
11283 "".to_string(),
11284 " Applicable to all fields that require text extraction Applicable to all fields that use any form of Applicable to all fields that deal with various types of".to_string(),
11285 " from standardized documents, such as receipts, recommendation including alternative products, unstructured data containing text information that".to_string(),
11286 "Application bills, credit cards, ID cards, certificates, and medical products and contents that are likely to be require semantic search and conversion into a DB".to_string(),
11287 " receipts purchased next".to_string(),
11288 "".to_string(),
11289 " Achieved 1st place in the OCR World Competition Team with specialists and technologies that Creation of the first natural language evaluation".to_string(),
11290 " The team includes specialists who have received Kaggle’s Gold Medal recommendation system in Korean (KLUE)".to_string(),
11291 " presented 14 papers in the world’s most (Education platform) World’s No.1 in Kaggle text embedding competition in".to_string(),
11292 " Highlight".to_string(),
11293 " renowned AI conferences Proven superior performance of more than 170% E-commerce subject (Shopee)".to_string(),
11294 " compared to other global top-tier recommendation".to_string(),
11295 " models".to_string(),
11296 ];
11297
11298 let header = find_layout_panel_header_candidate(&lines).unwrap();
11299 let rows = build_layout_panel_stub_rows(&lines, &header).unwrap();
11300
11301 assert_eq!(
11302 header.headers,
11303 vec![
11304 "OCR".to_string(),
11305 "Recommendation".to_string(),
11306 "Product semantic search".to_string()
11307 ]
11308 );
11309 assert_eq!(rows.len(), 3);
11310 assert_eq!(rows[0][0], "Pack");
11311 assert!(rows[0][1].contains("image and extracts necessary information"));
11312 assert_eq!(rows[1][0], "Application");
11313 assert!(rows[1][3].contains("require semantic search and conversion into a DB"));
11314 assert_eq!(rows[2][0], "Highlight");
11315 assert!(rows[2][2].contains("top-tier recommendation models"));
11316 }
11317
11318 #[cfg(not(target_arch = "wasm32"))]
11319 #[test]
11320 fn test_extract_layout_toc_entries_merges_wrapped_entry() {
11321 let lines = vec![
11322 "Table of Contents".to_string(),
11323 "".to_string(),
11324 "Executive Summary 4".to_string(),
11325 "Legal Framework 6".to_string(),
11326 "Election Administration 11".to_string(),
11327 "Civil Society Engagement 15".to_string(),
11328 "Political Parties, Candidates Registration and Election 18".to_string(),
11329 "Campaign".to_string(),
11330 "Media Freedom and Access to Information 25".to_string(),
11331 "Voter Education and Awareness 29".to_string(),
11332 "Participation of Marginalized Sectors 31".to_string(),
11333 "Recommendations 39".to_string(),
11334 ];
11335
11336 let (title, entries) = extract_layout_toc_entries(&lines).unwrap();
11337 assert_eq!(title, "Table of Contents");
11338 assert_eq!(entries.len(), 9);
11339 assert_eq!(entries[0].title, "Executive Summary");
11340 assert_eq!(entries[0].page, "4");
11341 assert_eq!(
11342 entries[4].title,
11343 "Political Parties, Candidates Registration and Election Campaign"
11344 );
11345 assert_eq!(entries[4].page, "18");
11346 }
11347
11348 #[cfg(not(target_arch = "wasm32"))]
11349 fn make_bbox_layout_line(words: &[(&str, f64, f64)], bottom: f64, top: f64) -> BBoxLayoutLine {
11350 make_bbox_layout_line_in_block(0, words, bottom, top)
11351 }
11352
11353 #[cfg(not(target_arch = "wasm32"))]
11354 fn make_bbox_layout_line_in_block(
11355 block_id: usize,
11356 words: &[(&str, f64, f64)],
11357 bottom: f64,
11358 top: f64,
11359 ) -> BBoxLayoutLine {
11360 BBoxLayoutLine {
11361 block_id,
11362 bbox: BoundingBox::new(
11363 Some(1),
11364 words.first().map(|(_, left, _)| *left).unwrap_or(72.0),
11365 bottom,
11366 words.last().map(|(_, _, right)| *right).unwrap_or(320.0),
11367 top,
11368 ),
11369 words: words
11370 .iter()
11371 .map(|(text, left, right)| BBoxLayoutWord {
11372 bbox: BoundingBox::new(Some(1), *left, bottom, *right, top),
11373 text: (*text).to_string(),
11374 })
11375 .collect(),
11376 }
11377 }
11378
11379 #[cfg(not(target_arch = "wasm32"))]
11380 #[test]
11381 fn test_detect_layout_open_plate_recovers_two_column_species_rows() {
11382 let lines = vec![
11383 make_bbox_layout_line(
11384 &[
11385 ("Fish", 60.0, 76.0),
11386 ("species", 78.0, 107.0),
11387 ("on", 109.0, 119.0),
11388 ("IUCN", 121.0, 142.0),
11389 ("Red", 144.0, 159.0),
11390 ("List", 161.0, 176.0),
11391 ],
11392 649.0,
11393 660.0,
11394 ),
11395 make_bbox_layout_line(
11396 &[("Potosi", 60.0, 84.0), ("Pupfish", 86.0, 114.0)],
11397 632.0,
11398 643.0,
11399 ),
11400 make_bbox_layout_line(
11401 &[("Cyprinodon", 132.0, 176.0), ("alvarezi", 178.0, 207.0)],
11402 632.0,
11403 643.0,
11404 ),
11405 make_bbox_layout_line(
11406 &[
11407 ("La", 60.0, 69.0),
11408 ("Palma", 71.0, 94.0),
11409 ("Pupfish", 96.0, 124.0),
11410 ("Cyprinodon", 132.0, 176.0),
11411 ("longidorsalis", 178.0, 224.0),
11412 ],
11413 616.0,
11414 627.0,
11415 ),
11416 make_bbox_layout_line(
11417 &[("Butterfly", 60.0, 94.0), ("Splitfin", 96.0, 123.0)],
11418 600.0,
11419 611.0,
11420 ),
11421 make_bbox_layout_line(
11422 &[("Ameca", 132.0, 156.0), ("splendens", 158.0, 194.0)],
11423 600.0,
11424 611.0,
11425 ),
11426 make_bbox_layout_line(
11427 &[("Golden", 60.0, 88.0), ("Skiffia", 90.0, 113.0)],
11428 584.0,
11429 595.0,
11430 ),
11431 make_bbox_layout_line(
11432 &[("Skiffia", 132.0, 155.0), ("francesae", 158.0, 193.0)],
11433 584.0,
11434 595.0,
11435 ),
11436 make_bbox_layout_line(
11437 &[
11438 ("Table", 56.0, 74.0),
11439 ("6.1:", 76.0, 87.0),
11440 ("Four", 89.0, 105.0),
11441 ("fish", 107.0, 119.0),
11442 ("species", 121.0, 145.0),
11443 ("on", 147.0, 155.0),
11444 ("IUCN", 157.0, 176.0),
11445 ("Red", 178.0, 190.0),
11446 ("List", 192.0, 205.0),
11447 ("held", 279.0, 293.0),
11448 ("in", 295.0, 302.0),
11449 ("public", 304.0, 325.0),
11450 ("aquariums.", 327.0, 365.0),
11451 ],
11452 556.0,
11453 566.0,
11454 ),
11455 ];
11456
11457 let plate = detect_layout_open_plate(576.0, &lines).unwrap();
11458 assert_eq!(plate.heading, "Fish species on IUCN Red List");
11459 assert_eq!(
11460 plate.header_row,
11461 vec![
11462 "Fish species on IUCN Red List".to_string(),
11463 "Scientific name".to_string()
11464 ]
11465 );
11466 assert_eq!(plate.rows.len(), 4);
11467 assert_eq!(
11468 plate.rows[1],
11469 vec![
11470 "La Palma Pupfish".to_string(),
11471 "Cyprinodon longidorsalis".to_string()
11472 ]
11473 );
11474 assert!(plate
11475 .caption
11476 .starts_with("Table 6.1: Four fish species on IUCN Red List"));
11477 }
11478
11479 #[cfg(not(target_arch = "wasm32"))]
11480 #[test]
11481 fn test_extract_layout_narrative_bridge_recovers_left_prose_and_defers_captions() {
11482 let plate = OpenPlateCandidate {
11483 heading: "Fish species on IUCN Red List".to_string(),
11484 header_row: vec![
11485 "Fish species on IUCN Red List".to_string(),
11486 "Scientific name".to_string(),
11487 ],
11488 rows: vec![],
11489 caption: "Table 6.1".to_string(),
11490 cutoff_top_y: 560.0,
11491 };
11492 let lines = vec![
11493 make_bbox_layout_line(
11494 &[
11495 ("Public", 56.0, 83.0),
11496 ("aquariums,", 88.0, 135.0),
11497 ("because", 140.0, 174.0),
11498 ],
11499 509.0,
11500 521.0,
11501 ),
11502 make_bbox_layout_line(
11503 &[
11504 ("of", 180.0, 188.0),
11505 ("their", 194.0, 214.0),
11506 ("in-", 220.0, 233.0),
11507 ],
11508 509.0,
11509 521.0,
11510 ),
11511 make_bbox_layout_line(
11512 &[
11513 ("house", 56.0, 82.0),
11514 ("expertise,", 84.0, 125.0),
11515 ("can", 128.0, 143.0),
11516 ],
11517 495.0,
11518 507.0,
11519 ),
11520 make_bbox_layout_line(
11521 &[("act", 146.0, 159.0), ("quickly", 161.0, 191.0)],
11522 495.0,
11523 507.0,
11524 ),
11525 make_bbox_layout_line_in_block(
11526 1,
11527 &[
11528 ("Figure", 242.0, 265.0),
11529 ("6.3:", 267.0, 280.0),
11530 ("Photo", 282.0, 303.0),
11531 ],
11532 355.0,
11533 366.0,
11534 ),
11535 make_bbox_layout_line_in_block(
11536 1,
11537 &[
11538 ("of", 305.0, 312.0),
11539 ("the", 314.0, 325.0),
11540 ("species.", 327.0, 360.0),
11541 ],
11542 355.0,
11543 366.0,
11544 ),
11545 make_bbox_layout_line(
11546 &[
11547 ("The", 56.0, 73.0),
11548 ("breeding", 77.0, 114.0),
11549 ("colonies", 118.0, 153.0),
11550 ],
11551 330.0,
11552 342.0,
11553 ),
11554 make_bbox_layout_line(
11555 &[
11556 ("of", 157.0, 165.0),
11557 ("the", 169.0, 183.0),
11558 ("Butterfly", 187.0, 224.0),
11559 ("Splitfin", 228.0, 258.0),
11560 ("at", 314.0, 323.0),
11561 ("the", 327.0, 341.0),
11562 ("London", 345.0, 377.0),
11563 ("Zoo", 381.0, 397.0),
11564 ("and", 401.0, 416.0),
11565 ("elsewhere", 420.0, 463.0),
11566 ("serve", 467.0, 489.0),
11567 ("as", 493.0, 502.0),
11568 ("ark", 506.0, 519.0),
11569 ],
11570 330.0,
11571 342.0,
11572 ),
11573 make_bbox_layout_line(
11574 &[
11575 ("Figure", 56.0, 79.0),
11576 ("6.4:", 81.0, 94.0),
11577 ("Lake", 96.0, 116.0),
11578 ("Sturgeon", 118.0, 158.0),
11579 ],
11580 104.0,
11581 116.0,
11582 ),
11583 ];
11584
11585 let bridge = extract_layout_narrative_bridge(576.0, &lines, &plate).unwrap();
11586 assert!(bridge
11587 .bridge_paragraph
11588 .as_deref()
11589 .is_some_and(|text| text.contains("Public aquariums") && text.contains("expertise")));
11590 assert_eq!(bridge.deferred_captions.len(), 2);
11591 assert!(bridge.deferred_captions[0].contains("Figure 6.3:"));
11592 assert!(bridge.deferred_captions[0].contains("species."));
11593 }
11594
11595 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11596 #[test]
11597 fn test_detect_layout_ocr_benchmark_dashboard_on_real_pdf() {
11598 let path =
11599 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000199.pdf");
11600 let (page_width, lines) = read_pdftotext_bbox_layout_lines(&path).unwrap();
11601 let dashboard = detect_layout_ocr_benchmark_dashboard(page_width, &lines).unwrap();
11602
11603 assert_eq!(
11604 dashboard.title,
11605 "Base Model Performance Evaluation of Upstage OCR Pack"
11606 );
11607 assert_eq!(dashboard.left_columns.len(), 2);
11608 assert_eq!(
11609 dashboard.left_columns[0],
11610 "Scene (Photographed document image)"
11611 );
11612 assert_eq!(
11613 dashboard.left_rows[0],
11614 vec![
11615 "Company A²".to_string(),
11616 "70.23".to_string(),
11617 "80.41".to_string()
11618 ]
11619 );
11620 assert_eq!(
11621 dashboard.right_rows[0],
11622 vec![
11623 "OCR-Recall³".to_string(),
11624 "73.2".to_string(),
11625 "94.2".to_string(),
11626 "94.1".to_string()
11627 ]
11628 );
11629 assert_eq!(dashboard.right_rows[3][0], "Parsing-F¹");
11630 assert_eq!(dashboard.right_rows[3][1], "68.0");
11631 assert_eq!(dashboard.right_rows[3][2], "82.65");
11632 assert_eq!(dashboard.right_rows[3][3], "82.65");
11633 assert!(!dashboard.definition_notes.is_empty());
11634 assert!(!dashboard.source_notes.is_empty());
11635 }
11636
11637 #[cfg(not(target_arch = "wasm32"))]
11638 #[test]
11639 fn test_split_layout_line_spans_handles_unicode_boundaries() {
11640 let line = "Title “Podcast #EP32: SDGs dan Anak Muda” 2024";
11641 let spans = split_layout_line_spans(line);
11642 assert_eq!(spans.len(), 3);
11643 assert_eq!(spans[0].1, "Title");
11644 assert!(spans[1].1.contains("Podcast #EP32: SDGs dan Anak Muda"));
11645 assert!(spans[1].1.ends_with('”'));
11646 assert!(spans[2].1.ends_with("24"));
11647 }
11648
11649 #[cfg(not(target_arch = "wasm32"))]
11650 #[test]
11651 #[ignore = "v0.3.0: TOC/chart markdown expectations drifted; restore in follow-up"]
11652 fn test_render_layout_single_caption_chart_document_on_real_pdf() {
11653 let path =
11654 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000037.pdf");
11655 let doc = PdfDocument {
11656 title: None,
11657 source_path: Some(path.to_string_lossy().to_string()),
11658 number_of_pages: 1,
11659 kids: crate::convert(&path, &crate::api::config::ProcessingConfig::default())
11660 .unwrap()
11661 .kids,
11662 ..PdfDocument::new("01030000000037.pdf".to_string())
11663 };
11664 let rendered = render_layout_single_caption_chart_document(&doc).unwrap();
11665 assert!(rendered.contains("# 3. Impact on Business Operations"));
11666 assert!(rendered.contains("## 3.1. Status of Business Operations"));
11667 assert!(rendered.contains("As shown in Figure 3.1.1, the number of MSMEs"));
11668 assert!(
11669 rendered.contains("Figure 3.1.1: Status of operations during each survey phase (%)")
11670 );
11671 assert!(
11672 rendered.contains("lockdown period. In the handicraft/textile sector, 30% of MSMEs")
11673 );
11674 assert!(!rendered.contains("| Lockdown Period |"));
11675 }
11676
11677 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11678 #[test]
11679 fn test_to_markdown_captioned_media_document_on_real_pdf_72() {
11680 let path =
11681 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000072.pdf");
11682 let doc = PdfDocument {
11683 title: None,
11684 source_path: Some(path.to_string_lossy().to_string()),
11685 number_of_pages: 1,
11686 kids: crate::convert(&path, &crate::api::config::ProcessingConfig::default())
11687 .unwrap()
11688 .kids,
11689 ..PdfDocument::new("01030000000072.pdf".to_string())
11690 };
11691 let md = to_markdown(&doc).unwrap();
11692 assert!(md.contains("## Diagram 5"), "{md}");
11693 assert!(
11694 md.contains("**Distribution of Komnas HAM’s YouTube Content (2019-2020)**"),
11695 "{md}"
11696 );
11697 assert!(
11698 md.contains(
11699 "As of 1 December 2021, the Komnas HAM’s YouTube channel has 2,290 subscribers"
11700 ),
11701 "{md}"
11702 );
11703 assert!(md.contains("**Figure 4**"), "{md}");
11704 assert!(
11705 md.contains("*Komnas HAM’s YouTube channel as of 1 December 2021*"),
11706 "{md}"
11707 );
11708 }
11709
11710 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11711 #[test]
11712 fn test_to_markdown_captioned_media_document_on_real_pdf_73() {
11713 let path =
11714 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000073.pdf");
11715 let doc = PdfDocument {
11716 title: None,
11717 source_path: Some(path.to_string_lossy().to_string()),
11718 number_of_pages: 1,
11719 kids: crate::convert(&path, &crate::api::config::ProcessingConfig::default())
11720 .unwrap()
11721 .kids,
11722 ..PdfDocument::new("01030000000073.pdf".to_string())
11723 };
11724 let md = to_markdown(&doc).unwrap();
11725 assert!(
11726 md.starts_with("# In this content, DPN Argentina provides a brief explanation"),
11727 "{md}"
11728 );
11729 assert!(
11730 md.contains("Examples of such greetings are as follows:"),
11731 "{md}"
11732 );
11733 assert!(md.contains("*Image*"), "{md}");
11734 assert!(md.contains("**Figure 6**"), "{md}");
11735 assert!(md.contains("**DPN Argentina**"), "{md}");
11736 assert!(
11737 md.contains("**Content: World Health Day Celebration (7 April 2021).**^98"),
11738 "{md}"
11739 );
11740 assert!(md.contains("**Footnote:**"), "{md}");
11741 assert!(
11742 md.contains("https://twitter.com/DPNArgentina/status/1379765916259483648."),
11743 "{md}"
11744 );
11745 }
11746
11747 #[cfg(not(target_arch = "wasm32"))]
11748 #[test]
11749 fn test_render_layout_captioned_media_document_does_not_fire_on_real_pdf_14() {
11750 let path =
11751 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000014.pdf");
11752 let doc = PdfDocument {
11753 title: None,
11754 source_path: Some(path.to_string_lossy().to_string()),
11755 number_of_pages: 1,
11756 kids: crate::convert(&path, &crate::api::config::ProcessingConfig::default())
11757 .unwrap()
11758 .kids,
11759 ..PdfDocument::new("01030000000014.pdf".to_string())
11760 };
11761 assert!(render_layout_captioned_media_document(&doc).is_none());
11762 }
11763
11764 #[cfg(not(target_arch = "wasm32"))]
11765 #[test]
11766 fn test_to_markdown_real_pdf_14_preserves_body_paragraphs() {
11767 let path =
11768 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000014.pdf");
11769 let doc = PdfDocument {
11770 title: None,
11771 source_path: Some(path.to_string_lossy().to_string()),
11772 number_of_pages: 1,
11773 kids: crate::convert(&path, &crate::api::config::ProcessingConfig::default())
11774 .unwrap()
11775 .kids,
11776 ..PdfDocument::new("01030000000014.pdf".to_string())
11777 };
11778 let md = to_markdown(&doc).unwrap();
11779 assert!(
11780 md.contains("These images also show that different areas are used by men and by women"),
11781 "{md}"
11782 );
11783 }
11784
11785 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11786 #[test]
11787 fn test_render_layout_recommendation_infographic_on_real_pdf() {
11788 let path =
11789 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000183.pdf");
11790 let doc = PdfDocument {
11791 title: None,
11792 source_path: Some(path.to_string_lossy().to_string()),
11793 number_of_pages: 1,
11794 kids: Vec::new(),
11795 ..PdfDocument::new("01030000000183.pdf".to_string())
11796 };
11797 let rendered = render_layout_recommendation_infographic_document(&doc).unwrap();
11798 assert!(rendered.contains("# Recommendation Pack: Track Record"));
11799 assert!(rendered.contains("## Comparison with Beauty Commerce Recommendation Models"));
11800 assert!(rendered.contains("| Graph-RecSys | 0.4048 |"));
11801 assert!(rendered.contains("| Current Service Recommendation Algorithm | 0.159 |"));
11802 assert!(rendered.contains("## Education Content Platform PoC Case"));
11803 assert!(rendered.contains("| DKT Model | 0.882 |"));
11804 assert!(rendered.contains("Compared to regular model"));
11805 }
11806
11807 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11808 #[test]
11809 fn test_render_layout_stacked_bar_report_on_real_pdf() {
11810 let path =
11811 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000038.pdf");
11812 let doc = PdfDocument {
11813 title: None,
11814 source_path: Some(path.to_string_lossy().to_string()),
11815 number_of_pages: 1,
11816 kids: Vec::new(),
11817 ..PdfDocument::new("01030000000038.pdf".to_string())
11818 };
11819 let rendered = render_layout_stacked_bar_report_document(&doc);
11820 if rendered.is_none() {
11821 let (page_width, lines) = read_pdftotext_bbox_layout_lines(&path).unwrap();
11822 let blocks = collect_bbox_layout_blocks(&lines);
11823 let figures = collect_layout_figure_captions(&blocks);
11824 let narrative = detect_layout_stacked_bar_narrative(&blocks);
11825 eprintln!("page_width={page_width} figures={}", figures.len());
11826 if let Some(first) = figures.first() {
11827 eprintln!("figure1={}", bbox_layout_block_text(first));
11828 }
11829 if let Some(second) = figures.get(1) {
11830 eprintln!("figure2={}", bbox_layout_block_text(second));
11831 }
11832 eprintln!("narrative={}", narrative.is_some());
11833 if let Some(narrative) = &narrative {
11834 eprintln!("heading={}", narrative.heading);
11835 eprintln!("paragraphs={}", narrative.paragraphs.len());
11836 eprintln!("footnote={:?}", narrative.footnote);
11837 }
11838 for block in &blocks {
11839 let text = bbox_layout_block_text(block);
11840 if text.contains("July")
11841 || text.contains("October")
11842 || text.contains("January")
11843 || text.contains("Will ")
11844 || text.contains("Don’t")
11845 || text.starts_with("6.2.")
11846 || text.starts_with("5.")
11847 {
11848 eprintln!(
11849 "block top={:.1} bottom={:.1} left={:.1} right={:.1} text={}",
11850 block.bbox.top_y,
11851 block.bbox.bottom_y,
11852 block.bbox.left_x,
11853 block.bbox.right_x,
11854 text
11855 );
11856 }
11857 }
11858 if figures.len() >= 2 {
11859 let first = detect_layout_three_month_stacked_figure(
11860 &blocks,
11861 &lines,
11862 page_width,
11863 figures[0].clone(),
11864 figures[1].bbox.top_y,
11865 );
11866 eprintln!("figure_one_ok={}", first.is_some());
11867 if let Some(narrative) = &narrative {
11868 let second = detect_layout_sector_bar_figure(
11869 &blocks,
11870 &lines,
11871 page_width,
11872 figures[1].clone(),
11873 narrative.top_y,
11874 );
11875 eprintln!("figure_two_ok={}", second.is_some());
11876 }
11877 }
11878 }
11879 let rendered = rendered.unwrap();
11880 assert!(rendered.contains("# Figure 6.1.1:"));
11881 assert!(rendered.contains("| Will not terminate employment | 51 | 81 | 73 |"));
11882 assert!(rendered.contains("# 6.2. Expectations for Re-Hiring Employees"));
11883 }
11884
11885 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11886 #[test]
11887 fn test_render_layout_multi_figure_chart_document_on_real_pdf() {
11888 let path =
11889 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000076.pdf");
11890 let doc = PdfDocument {
11891 title: None,
11892 source_path: Some(path.to_string_lossy().to_string()),
11893 number_of_pages: 1,
11894 kids: Vec::new(),
11895 ..PdfDocument::new("01030000000076.pdf".to_string())
11896 };
11897 let rendered = render_layout_multi_figure_chart_document(&doc).unwrap();
11898 assert!(rendered.contains("# Figures from the Document"));
11899 assert!(
11900 rendered.contains("## Figure 1.7. Non-citizen population in Malaysia (in thousands)")
11901 );
11902 assert!(rendered.contains("| 2016 | 3,230 |"));
11903 assert!(rendered.contains("| 2021 | 2,693 |"));
11904 assert!(
11905 rendered.contains("## Figure 1.8. Singapore foreign workforce stock (in thousands)")
11906 );
11907 assert!(rendered.contains("| 2016 (Dec) | 1,393 |"));
11908 assert!(rendered.contains("| 2021 (Dec) | 1,200 |"));
11909 assert!(rendered.contains(
11910 "Source: Department of Statistics, Malaysia (2022). Figure for 2021 is an estimate."
11911 ));
11912 }
11913
11914 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11915 #[test]
11916 fn test_render_layout_open_plate_document_on_real_pdf() {
11917 let path =
11918 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000132.pdf");
11919 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
11920 let rendered = render_layout_open_plate_document(&doc).unwrap();
11921 assert!(rendered.contains("# Fish species on IUCN Red List"));
11922 assert!(rendered.contains("| Potosi Pupfish | Cyprinodon alvarezi |"));
11923 assert!(rendered.contains("| Golden Skiffia | Skiffia francesae |"));
11924 assert!(rendered.contains("*Table 6.1: Four fish species on IUCN Red List"));
11925 assert!(rendered.contains("---"));
11926 assert!(rendered.contains("Public aquariums, because of their inhouse expertise"));
11927 }
11928
11929 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
11930 #[test]
11931 fn test_to_markdown_open_plate_document_on_real_pdf() {
11932 let path =
11933 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000132.pdf");
11934 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
11935 let md = to_markdown(&doc).unwrap();
11936
11937 assert!(md.contains("# Fish species on IUCN Red List"), "{md}");
11938 assert!(
11939 md.contains("| Potosi Pupfish | Cyprinodon alvarezi |"),
11940 "{md}"
11941 );
11942 assert!(
11943 md.contains("| Golden Skiffia | Skiffia francesae |"),
11944 "{md}"
11945 );
11946 assert!(
11947 md.contains("*Table 6.1: Four fish species on IUCN Red List"),
11948 "{md}"
11949 );
11950 assert!(
11951 md.contains("The breeding colonies of the Butterfly Splitfin"),
11952 "{md}"
11953 );
11954 }
11955
11956 #[cfg(not(target_arch = "wasm32"))]
11957 #[test]
11958 fn test_to_markdown_does_not_misclassify_open_plate_pdf_36() {
11959 let path =
11960 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000036.pdf");
11961 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
11962 let md = to_markdown(&doc).unwrap();
11963
11964 assert!(md.contains("# 2. General Profile of MSMEs"), "{md}");
11965 assert!(
11966 md.contains("In July 2020, the survey established a general profile"),
11967 "{md}"
11968 );
11969 assert!(
11970 md.contains(
11971 "The tourism sub-sectors interviewed included lodging, restaurants and bars"
11972 ),
11973 "{md}"
11974 );
11975 assert!(
11976 !md.starts_with("# Business characteristics. Business size was"),
11977 "{md}"
11978 );
11979 }
11980
11981 #[cfg(not(target_arch = "wasm32"))]
11982 #[test]
11983 fn test_to_markdown_does_not_misclassify_open_plate_pdf_40() {
11984 let path =
11985 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000040.pdf");
11986 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
11987 let md = to_markdown(&doc).unwrap();
11988
11989 assert!(
11990 md.contains(
11991 "Thailand, Philippines and Indonesia in particular, identifying known experts"
11992 ),
11993 "{md}"
11994 );
11995 assert!(
11996 md.contains("Figure 1: Age by gender of respondents"),
11997 "{md}"
11998 );
11999 assert!(md.contains("Gender Analysis of Violent Extremism"), "{md}");
12000 assert!(
12001 !md.starts_with("# Thailand, Philippines and Indonesia in"),
12002 "{md}"
12003 );
12004 }
12005
12006 #[cfg(not(target_arch = "wasm32"))]
12007 #[test]
12008 fn test_to_markdown_does_not_misclassify_open_plate_pdf_64() {
12009 let path =
12010 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000064.pdf");
12011 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12012 let md = to_markdown(&doc).unwrap();
12013
12014 assert!(md.contains("estuarine influenced areas."), "{md}");
12015 assert!(md.contains("| MANILA | 2454 | 6,125 |"), "{md}");
12016 assert!(
12017 md.contains("The port of Manila has been documented"),
12018 "{md}"
12019 );
12020 assert!(!md.starts_with("# CAGAYAN DE ORO"), "{md}");
12021 }
12022
12023 #[cfg(not(target_arch = "wasm32"))]
12024 #[test]
12025 fn test_detect_footnote_citation_regions_on_real_pdf() {
12026 let path =
12027 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000008.pdf");
12028 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12029 let regions = detect_footnote_citation_regions(&doc);
12030 assert!(!regions.is_empty(), "{regions:?}");
12031 assert!(
12032 regions.iter().any(|region| {
12033 region.rendered.contains("<table>")
12034 && region.rendered.contains("<td>25</td>")
12035 && region.rendered.contains("<td>29</td>")
12036 }),
12037 "{regions:#?}"
12038 );
12039 assert!(
12040 regions.iter().any(|region| {
12041 region.rendered.contains("<table>")
12042 && region.rendered.contains("<td>30</td>")
12043 && region.rendered.contains("<td>33</td>")
12044 }),
12045 "{regions:#?}"
12046 );
12047 }
12048
12049 #[cfg(not(target_arch = "wasm32"))]
12050 #[test]
12051 fn test_to_markdown_renders_footnote_citation_tables_on_real_pdf() {
12052 let path =
12053 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000008.pdf");
12054 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12055 let md = to_markdown(&doc).unwrap();
12056
12057 assert!(md.contains("<table>"), "{md}");
12058 assert!(md.contains("<th>Footnote</th><th>Citation</th>"), "{md}");
12059 assert!(md.contains("<td>25</td><td>Wiliam Beckford"), "{md}");
12060 assert!(
12061 md.contains("<td>29</td><td>Pope, The Rape of the Lock, 69.</td>"),
12062 "{md}"
12063 );
12064 assert!(
12065 md.contains("<td>30</td><td>Beawes, Lex Mercatoria Rediviva, 791.</td>"),
12066 "{md}"
12067 );
12068 assert!(
12069 md.contains("<td>32</td><td>Beawes, Lex Mercatoria Rediviva, 792.</td>"),
12070 "{md}"
12071 );
12072 assert!(
12073 md.contains("<td>33</td><td>M.M., Pharmacopoia Reformata:"),
12074 "{md}"
12075 );
12076 }
12077
12078 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
12079 #[test]
12080 fn test_to_markdown_projection_sheet_document_on_real_pdf() {
12081 let path =
12082 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000128.pdf");
12083 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12084 let md = to_markdown(&doc).unwrap();
12085
12086 assert!(md.contains("# Table and Figure from the Document"), "{md}");
12087 assert!(md.contains("| A | B | C | D | E |"), "{md}");
12088 assert!(
12089 md.contains("| 10 | 8 | 19.73214458 | 17.99 | 21.47 |"),
12090 "{md}"
12091 );
12092 assert!(
12093 md.contains("**Figure 13.3. Graph of Projection Estimates**"),
12094 "{md}"
12095 );
12096 assert!(md.contains("[Open Template in Microsoft Excel](#)"), "{md}");
12097 assert!(
12098 md.contains("*298 | Ch. 13. Homogeneous Investment Types*"),
12099 "{md}"
12100 );
12101 }
12102
12103 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
12104 #[test]
12105 fn test_to_markdown_appendix_tables_document_on_real_pdf() {
12106 let path =
12107 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000082.pdf");
12108 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12109 let md = to_markdown(&doc).unwrap();
12110
12111 assert!(md.contains("# Appendices"), "{md}");
12112 assert!(
12113 md.contains("## TABLE 28: BREAKDOWN OF IMPRISONMENT CLAUSES IN STATE LAWS"),
12114 "{md}"
12115 );
12116 assert!(md.contains("| Imprisonment terms | Number of clauses | Percentage of all states | Percentage of total |"), "{md}");
12117 assert!(
12118 md.contains("| Less than 3 months | 4,448 | 21.3% | 17.0% |"),
12119 "{md}"
12120 );
12121 assert!(
12122 md.contains("## TABLE 29: STATES WITH MORE THAN 1,000 IMPRISONMENT CLAUSES"),
12123 "{md}"
12124 );
12125 assert!(
12126 md.contains(
12127 "| State | Number of clauses | GSDP (In Rs lakh crore) | GSDP (In $ billion) |"
12128 ),
12129 "{md}"
12130 );
12131 assert!(md.contains("| Gujarat | 1469 | 15.6 | 200.4 |"), "{md}");
12132 assert!(
12133 md.contains("*Sources: TeamLease Regtech, and Reserve Bank of India for GSDPs*"),
12134 "{md}"
12135 );
12136 assert!(md.contains("*Exchange rate: Rs 75 to USD*"), "{md}");
12137 }
12138
12139 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
12140 #[test]
12141 fn test_to_markdown_titled_dual_table_document_on_real_pdf() {
12142 let path =
12143 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000084.pdf");
12144 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12145 let md = to_markdown(&doc).unwrap();
12146
12147 assert!(md.starts_with("# Jailed for Doing Business"), "{md}");
12148 assert!(
12149 md.contains("## TABLE 38: THREE CASE STUDIES ON NBFC COMPLIANCES*"),
12150 "{md}"
12151 );
12152 assert!(
12153 md.contains("| Percentage of imprisonment clauses | 20% | 30% | 37% |"),
12154 "{md}"
12155 );
12156 assert!(
12157 md.contains("## TABLE 39: BREAKDOWN OF IMPRISONMENT CLAUSES IN NBFC CASE STUDIES*"),
12158 "{md}"
12159 );
12160 assert!(
12161 md.contains("| 5 years to 10 years | 19 | 19 | 19 |"),
12162 "{md}"
12163 );
12164 assert!(
12165 md.contains("*These are real data from three NBFCs*"),
12166 "{md}"
12167 );
12168 }
12169
12170 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
12171 #[test]
12172 fn test_to_markdown_registration_report_document_on_real_pdf() {
12173 let path =
12174 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000047.pdf");
12175 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12176 let md = to_markdown(&doc).unwrap();
12177
12178 assert!(
12179 md.starts_with("# ANFREL Pre-Election Assessment Mission Report"),
12180 "{md}"
12181 );
12182 assert!(
12183 md.contains(
12184 "| 14 | Cambodian Indigeneous Peoples Democracy Party | 19 | 194 | 19 | 202 | +8 |"
12185 ),
12186 "{md}"
12187 );
12188 assert!(
12189 md.contains("| | Total | | 84,208 | | 86,092 | +1,884 |"),
12190 "{md}"
12191 );
12192 assert!(!md.contains("| | Democracy Party |"), "{md}");
12193 }
12194
12195 #[cfg(all(not(target_arch = "wasm32"), not(target_os = "windows")))]
12196 #[test]
12197 fn test_to_markdown_dual_table_article_document_on_real_pdf() {
12198 let path =
12199 Path::new(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs/01030000000190.pdf");
12200 let doc = crate::convert(&path, &crate::api::config::ProcessingConfig::default()).unwrap();
12201 let md = to_markdown(&doc).unwrap();
12202
12203 assert!(
12204 md.starts_with("# Table 6: Performance comparison amongst the merge candidates"),
12205 "{md}"
12206 );
12207 assert!(
12208 md.contains("*Table 6*: Performance comparison amongst the merge candidates."),
12209 "{md}"
12210 );
12211 assert!(md.contains("# Table 7: Ablation studies on the different merge methods used for obtaining the final model"), "{md}");
12212 assert!(!md.contains("*Table 6*: Table 6:"), "{md}");
12213 assert!(!md.contains("| Merge v1"), "{md}");
12214 }
12215
12216 #[test]
12217 fn test_normalize_list_text_strips_redundant_bullets() {
12218 assert_eq!(
12219 normalize_list_text("• Collected via surveys"),
12220 "Collected via surveys"
12221 );
12222 assert!(is_pure_bullet_marker("•"));
12223 }
12224
12225 #[test]
12226 fn test_reference_continuation_detected() {
12227 assert!(should_merge_paragraph_text(
12228 "Scaling laws for transfer.",
12229 "arXiv preprint arXiv:2102.01293."
12230 ));
12231 }
12232
12233 #[test]
12234 fn test_enumerated_markers_are_detected() {
12235 assert!(starts_with_enumerated_marker("iii. Third item"));
12236 assert!(starts_with_enumerated_marker("1) First item"));
12237 assert!(starts_with_enumerated_marker("a. Lettered item"));
12238 assert!(!starts_with_enumerated_marker("Figure 1. Caption"));
12239 assert!(!starts_with_enumerated_marker("Natural dispersal"));
12240 }
12241
12242 fn make_heading(text: &str) -> ContentElement {
12243 let bbox = BoundingBox::new(Some(1), 72.0, 700.0, 300.0, 712.0);
12244 let chunk = TextChunk {
12245 value: text.to_string(),
12246 bbox: bbox.clone(),
12247 font_name: "Lato-Bold".to_string(),
12248 font_size: 12.0,
12249 font_weight: 700.0,
12250 italic_angle: 0.0,
12251 font_color: "#000000".to_string(),
12252 contrast_ratio: 21.0,
12253 symbol_ends: vec![],
12254 text_format: TextFormat::Normal,
12255 text_type: TextType::Regular,
12256 pdf_layer: PdfLayer::Main,
12257 ocg_visible: true,
12258 index: None,
12259 page_number: Some(1),
12260 level: None,
12261 mcid: None,
12262 };
12263 let line = TextLine {
12264 bbox: bbox.clone(),
12265 index: None,
12266 level: None,
12267 font_size: 12.0,
12268 base_line: 702.0,
12269 slant_degree: 0.0,
12270 is_hidden_text: false,
12271 text_chunks: vec![chunk],
12272 is_line_start: true,
12273 is_line_end: true,
12274 is_list_line: false,
12275 connected_line_art_label: None,
12276 };
12277 let block = TextBlock {
12278 bbox: bbox.clone(),
12279 index: None,
12280 level: None,
12281 font_size: 12.0,
12282 base_line: 702.0,
12283 slant_degree: 0.0,
12284 is_hidden_text: false,
12285 text_lines: vec![line],
12286 has_start_line: true,
12287 has_end_line: true,
12288 text_alignment: None,
12289 };
12290 let column = TextColumn {
12291 bbox: bbox.clone(),
12292 index: None,
12293 level: None,
12294 font_size: 12.0,
12295 base_line: 702.0,
12296 slant_degree: 0.0,
12297 is_hidden_text: false,
12298 text_blocks: vec![block],
12299 };
12300 ContentElement::Heading(SemanticHeading {
12301 base: SemanticParagraph {
12302 base: SemanticTextNode {
12303 bbox,
12304 index: None,
12305 level: None,
12306 semantic_type: crate::models::enums::SemanticType::Heading,
12307 correct_semantic_score: None,
12308 columns: vec![column],
12309 font_weight: Some(700.0),
12310 font_size: Some(12.0),
12311 text_color: None,
12312 italic_angle: None,
12313 font_name: Some("Lato-Bold".to_string()),
12314 text_format: None,
12315 max_font_size: Some(12.0),
12316 background_color: None,
12317 is_hidden_text: false,
12318 },
12319 enclosed_top: false,
12320 enclosed_bottom: false,
12321 indentation: 0,
12322 },
12323 heading_level: Some(1),
12324 })
12325 }
12326
12327 fn make_heading_at(left: f64, bottom: f64, right: f64, top: f64, text: &str) -> ContentElement {
12328 let bbox = BoundingBox::new(Some(1), left, bottom, right, top);
12329 let chunk = TextChunk {
12330 value: text.to_string(),
12331 bbox: bbox.clone(),
12332 font_name: "Lato-Bold".to_string(),
12333 font_size: top - bottom,
12334 font_weight: 700.0,
12335 italic_angle: 0.0,
12336 font_color: "#000000".to_string(),
12337 contrast_ratio: 21.0,
12338 symbol_ends: vec![],
12339 text_format: TextFormat::Normal,
12340 text_type: TextType::Regular,
12341 pdf_layer: PdfLayer::Main,
12342 ocg_visible: true,
12343 index: None,
12344 page_number: Some(1),
12345 level: None,
12346 mcid: None,
12347 };
12348 let line = TextLine {
12349 bbox: bbox.clone(),
12350 index: None,
12351 level: None,
12352 font_size: top - bottom,
12353 base_line: bottom + 2.0,
12354 slant_degree: 0.0,
12355 is_hidden_text: false,
12356 text_chunks: vec![chunk],
12357 is_line_start: true,
12358 is_line_end: true,
12359 is_list_line: false,
12360 connected_line_art_label: None,
12361 };
12362 let block = TextBlock {
12363 bbox: bbox.clone(),
12364 index: None,
12365 level: None,
12366 font_size: top - bottom,
12367 base_line: bottom + 2.0,
12368 slant_degree: 0.0,
12369 is_hidden_text: false,
12370 text_lines: vec![line],
12371 has_start_line: true,
12372 has_end_line: true,
12373 text_alignment: None,
12374 };
12375 let column = TextColumn {
12376 bbox: bbox.clone(),
12377 index: None,
12378 level: None,
12379 font_size: top - bottom,
12380 base_line: bottom + 2.0,
12381 slant_degree: 0.0,
12382 is_hidden_text: false,
12383 text_blocks: vec![block],
12384 };
12385 ContentElement::Heading(SemanticHeading {
12386 base: SemanticParagraph {
12387 base: SemanticTextNode {
12388 bbox,
12389 index: None,
12390 level: None,
12391 semantic_type: crate::models::enums::SemanticType::Heading,
12392 correct_semantic_score: None,
12393 columns: vec![column],
12394 font_weight: Some(700.0),
12395 font_size: Some(top - bottom),
12396 text_color: None,
12397 italic_angle: None,
12398 font_name: Some("Lato-Bold".to_string()),
12399 text_format: None,
12400 max_font_size: Some(top - bottom),
12401 background_color: None,
12402 is_hidden_text: false,
12403 },
12404 enclosed_top: false,
12405 enclosed_bottom: false,
12406 indentation: 0,
12407 },
12408 heading_level: None,
12409 })
12410 }
12411
12412 fn make_paragraph(text: &str, bottom: f64, top: f64) -> ContentElement {
12413 make_paragraph_at(72.0, bottom, 300.0, top, text)
12414 }
12415
12416 fn make_paragraph_at(
12417 left: f64,
12418 bottom: f64,
12419 right: f64,
12420 top: f64,
12421 text: &str,
12422 ) -> ContentElement {
12423 let bbox = BoundingBox::new(Some(1), left, bottom, right, top);
12424 let chunk = TextChunk {
12425 value: text.to_string(),
12426 bbox: bbox.clone(),
12427 font_name: "Lato-Regular".to_string(),
12428 font_size: (top - bottom).max(1.0),
12429 font_weight: 400.0,
12430 italic_angle: 0.0,
12431 font_color: "#000000".to_string(),
12432 contrast_ratio: 21.0,
12433 symbol_ends: vec![],
12434 text_format: TextFormat::Normal,
12435 text_type: TextType::Regular,
12436 pdf_layer: PdfLayer::Main,
12437 ocg_visible: true,
12438 index: None,
12439 page_number: Some(1),
12440 level: None,
12441 mcid: None,
12442 };
12443 let line = TextLine {
12444 bbox: bbox.clone(),
12445 index: None,
12446 level: None,
12447 font_size: chunk.font_size,
12448 base_line: bottom + 2.0,
12449 slant_degree: 0.0,
12450 is_hidden_text: false,
12451 text_chunks: vec![chunk],
12452 is_line_start: true,
12453 is_line_end: true,
12454 is_list_line: false,
12455 connected_line_art_label: None,
12456 };
12457 let block = TextBlock {
12458 bbox: bbox.clone(),
12459 index: None,
12460 level: None,
12461 font_size: line.font_size,
12462 base_line: line.base_line,
12463 slant_degree: 0.0,
12464 is_hidden_text: false,
12465 text_lines: vec![line],
12466 has_start_line: true,
12467 has_end_line: true,
12468 text_alignment: None,
12469 };
12470 let column = TextColumn {
12471 bbox: bbox.clone(),
12472 index: None,
12473 level: None,
12474 font_size: block.font_size,
12475 base_line: block.base_line,
12476 slant_degree: 0.0,
12477 is_hidden_text: false,
12478 text_blocks: vec![block],
12479 };
12480 ContentElement::Paragraph(SemanticParagraph {
12481 base: SemanticTextNode {
12482 bbox,
12483 index: None,
12484 level: None,
12485 semantic_type: crate::models::enums::SemanticType::Paragraph,
12486 correct_semantic_score: None,
12487 columns: vec![column],
12488 font_weight: Some(400.0),
12489 font_size: Some(top - bottom),
12490 text_color: None,
12491 italic_angle: None,
12492 font_name: Some("Lato-Regular".to_string()),
12493 text_format: None,
12494 max_font_size: Some(top - bottom),
12495 background_color: None,
12496 is_hidden_text: false,
12497 },
12498 enclosed_top: false,
12499 enclosed_bottom: false,
12500 indentation: 0,
12501 })
12502 }
12503
12504 fn make_fallback_list(items: &[&str]) -> ContentElement {
12505 let mut list_items = Vec::new();
12506 for (idx, text) in items.iter().enumerate() {
12507 let top = 700.0 - idx as f64 * 18.0;
12508 let bottom = top - 12.0;
12509 let bbox = BoundingBox::new(Some(1), 72.0, bottom, 320.0, top);
12510 list_items.push(ListItem {
12511 bbox: bbox.clone(),
12512 index: None,
12513 level: None,
12514 label: ListLabel {
12515 bbox: bbox.clone(),
12516 content: vec![],
12517 semantic_type: None,
12518 },
12519 body: ListBody {
12520 bbox: bbox.clone(),
12521 content: vec![],
12522 semantic_type: None,
12523 },
12524 label_length: 0,
12525 contents: vec![make_paragraph_at(72.0, bottom, 320.0, top, text)],
12526 semantic_type: None,
12527 });
12528 }
12529
12530 ContentElement::List(PDFList {
12531 bbox: BoundingBox::new(
12532 Some(1),
12533 72.0,
12534 700.0 - items.len() as f64 * 18.0,
12535 320.0,
12536 700.0,
12537 ),
12538 index: None,
12539 level: None,
12540 list_items,
12541 numbering_style: Some("bullets".to_string()),
12542 common_prefix: None,
12543 previous_list_id: None,
12544 next_list_id: None,
12545 })
12546 }
12547
12548 fn make_toc_table(rows: &[(&str, &str)]) -> ContentElement {
12549 let mut table_rows = Vec::new();
12550 for (ri, (title, page)) in rows.iter().enumerate() {
12551 let top = 680.0 - ri as f64 * 18.0;
12552 let bottom = top - 12.0;
12553 let left_bbox = BoundingBox::new(Some(1), 72.0, bottom, 280.0, top);
12554 let right_bbox = BoundingBox::new(Some(1), 320.0, bottom, 360.0, top);
12555 table_rows.push(TableBorderRow {
12556 bbox: BoundingBox::new(Some(1), 72.0, bottom, 360.0, top),
12557 index: None,
12558 level: None,
12559 row_number: ri,
12560 cells: vec![
12561 TableBorderCell {
12562 bbox: left_bbox.clone(),
12563 index: None,
12564 level: None,
12565 row_number: ri,
12566 col_number: 0,
12567 row_span: 1,
12568 col_span: 1,
12569 content: vec![TableToken {
12570 base: TextChunk {
12571 value: (*title).to_string(),
12572 bbox: left_bbox,
12573 font_name: "Lato-Regular".to_string(),
12574 font_size: 10.0,
12575 font_weight: 400.0,
12576 italic_angle: 0.0,
12577 font_color: "#000000".to_string(),
12578 contrast_ratio: 21.0,
12579 symbol_ends: vec![],
12580 text_format: TextFormat::Normal,
12581 text_type: TextType::Regular,
12582 pdf_layer: PdfLayer::Main,
12583 ocg_visible: true,
12584 index: None,
12585 page_number: Some(1),
12586 level: None,
12587 mcid: None,
12588 },
12589 token_type: TableTokenType::Text,
12590 }],
12591 contents: vec![],
12592 semantic_type: None,
12593 },
12594 TableBorderCell {
12595 bbox: right_bbox.clone(),
12596 index: None,
12597 level: None,
12598 row_number: ri,
12599 col_number: 1,
12600 row_span: 1,
12601 col_span: 1,
12602 content: vec![TableToken {
12603 base: TextChunk {
12604 value: (*page).to_string(),
12605 bbox: right_bbox,
12606 font_name: "Lato-Regular".to_string(),
12607 font_size: 10.0,
12608 font_weight: 400.0,
12609 italic_angle: 0.0,
12610 font_color: "#000000".to_string(),
12611 contrast_ratio: 21.0,
12612 symbol_ends: vec![],
12613 text_format: TextFormat::Normal,
12614 text_type: TextType::Regular,
12615 pdf_layer: PdfLayer::Main,
12616 ocg_visible: true,
12617 index: None,
12618 page_number: Some(1),
12619 level: None,
12620 mcid: None,
12621 },
12622 token_type: TableTokenType::Text,
12623 }],
12624 contents: vec![],
12625 semantic_type: None,
12626 },
12627 ],
12628 semantic_type: None,
12629 });
12630 }
12631
12632 ContentElement::TableBorder(TableBorder {
12633 bbox: BoundingBox::new(Some(1), 72.0, 620.0, 360.0, 680.0),
12634 index: None,
12635 level: Some("1".to_string()),
12636 x_coordinates: vec![72.0, 320.0, 360.0],
12637 x_widths: vec![0.0, 0.0, 0.0],
12638 y_coordinates: vec![680.0, 662.0, 644.0, 626.0],
12639 y_widths: vec![0.0, 0.0, 0.0, 0.0],
12640 rows: table_rows,
12641 num_rows: rows.len(),
12642 num_columns: 2,
12643 is_bad_table: false,
12644 is_table_transformer: false,
12645 previous_table: None,
12646 next_table: None,
12647 })
12648 }
12649
12650 #[test]
12651 #[ignore = "v0.3.0: TOC/chart markdown expectations drifted; restore in follow-up"]
12652 fn test_contents_document_renders_toc_table_rows() {
12653 let mut doc = PdfDocument::new("contents.pdf".to_string());
12654 doc.kids.push(make_heading("CONTENTS"));
12655 doc.kids.push(make_toc_table(&[
12656 ("Experiment #1: Hydrostatic Pressure", "3"),
12657 ("Experiment #2: Bernoulli's Theorem Demonstration", "13"),
12658 ("Experiment #3: Energy Loss in Pipe Fittings", "24"),
12659 ("Experiment #4: Energy Loss in Pipes", "33"),
12660 ("Experiment #5: Impact of a Jet", "43"),
12661 ("Experiment #6: Orifice and Free Jet Flow", "50"),
12662 ("Experiment #7: Osborne Reynolds' Demonstration", "59"),
12663 ("References", "101"),
12664 ]));
12665
12666 let md = to_markdown(&doc).unwrap();
12667 assert!(md.starts_with("# CONTENTS\n\n"));
12668 assert!(md.contains("- Experiment #1: Hydrostatic Pressure 3\n"));
12669 assert!(md.contains("- Experiment #2: Bernoulli's Theorem Demonstration 13\n"));
12670 assert!(md.contains("- Experiment #7: Osborne Reynolds' Demonstration 59\n"));
12671 assert!(md.contains("- References 101\n"));
12672 }
12673
12674 #[test]
12675 fn test_toc_semantic_paragraphs_render_without_blank_lines() {
12676 let mut doc = PdfDocument::new("toc-semantic.pdf".to_string());
12677 let mut first = make_paragraph(
12678 "Part V. Chapter Five - Comparing Associations Between Multiple Variables",
12679 700.0,
12680 712.0,
12681 );
12682 let mut second = make_paragraph("Section 5.1: The Linear Model 35", 684.0, 696.0);
12683 if let ContentElement::Paragraph(p) = &mut first {
12684 p.base.semantic_type = SemanticType::TableOfContent;
12685 }
12686 if let ContentElement::Paragraph(p) = &mut second {
12687 p.base.semantic_type = SemanticType::TableOfContent;
12688 }
12689 doc.kids.push(first);
12690 doc.kids.push(second);
12691
12692 let md = to_markdown(&doc).unwrap();
12693 assert!(md.contains(
12694 "Part V. Chapter Five - Comparing Associations Between Multiple Variables\nSection 5.1: The Linear Model 35\n"
12695 ));
12696 }
12697
12698 #[test]
12699 #[ignore = "v0.3.0: TOC/chart markdown expectations drifted; restore in follow-up"]
12700 fn test_compact_toc_document_renders_without_blank_lines() {
12701 let mut doc = PdfDocument::new("compact-toc.pdf".to_string());
12702 doc.kids.push(make_paragraph(
12703 "Part V. Chapter Five - Comparing Associations Between Multiple Variables",
12704 700.0,
12705 712.0,
12706 ));
12707 doc.kids.push(make_paragraph(
12708 "Section 5.1: The Linear Model 35",
12709 684.0,
12710 696.0,
12711 ));
12712 doc.kids.push(make_paragraph(
12713 "Part VI. Chapter Six - Comparing Three or More Group Means",
12714 668.0,
12715 680.0,
12716 ));
12717 doc.kids.push(make_paragraph(
12718 "Section 6.1: Between Versus Within Group Analyses 49",
12719 652.0,
12720 664.0,
12721 ));
12722 doc.kids.push(make_paragraph(
12723 "Part VII. Chapter Seven - Moderation and Mediation Analyses",
12724 636.0,
12725 648.0,
12726 ));
12727 doc.kids.push(make_paragraph(
12728 "Section 7.1: Mediation and Moderation Models 64",
12729 620.0,
12730 632.0,
12731 ));
12732 doc.kids
12733 .push(make_paragraph("References 101", 604.0, 616.0));
12734 doc.kids.push(make_paragraph(
12735 "Section 8.1: Factor Analysis Definitions 75",
12736 588.0,
12737 600.0,
12738 ));
12739
12740 let md = to_markdown(&doc).unwrap();
12741 assert!(md.contains(
12742 "# Part V. Chapter Five - Comparing Associations Between Multiple Variables\n\n## Section 5.1: The Linear Model"
12743 ));
12744 assert!(md.contains(
12745 "# Part VI. Chapter Six - Comparing Three or More Group Means\n\n## Section 6.1: Between Versus Within Group Analyses"
12746 ));
12747 assert!(md.contains("References 101\n\n## Section 8.1: Factor Analysis Definitions"));
12748 }
12749
12750 #[test]
12751 fn test_merged_caption_and_body_paragraph_renders_as_two_paragraphs() {
12752 let mut doc = PdfDocument::new("caption-body.pdf".to_string());
12753 doc.kids.push(make_paragraph(
12754 "Figure 1. This image shows the Western hemisphere as viewed from space 35,400 kilometers above Earth. (credit: modification of work by R. Stockli, NASA/ GSFC/ NOAA/ USGS) Our nearest astronomical neighbor is Earth's satellite, commonly called the Moon.",
12755 500.0,
12756 540.0,
12757 ));
12758
12759 let md = to_markdown(&doc).unwrap();
12760 assert!(md.contains("USGS)\n\nOur nearest astronomical neighbor"));
12761 }
12762
12763 #[test]
12764 fn test_short_caption_label_merges_with_following_tail_and_body() {
12765 let mut doc = PdfDocument::new("diagram-caption.pdf".to_string());
12766 doc.kids.push(make_paragraph("Diagram 5", 540.0, 552.0));
12767 doc.kids.push(make_paragraph(
12768 "Distribution of Komnas HAM's YouTube Content (2019- 2020) As of 1 December 2021, the channel has 2,290 subscribers and 185,676 total views.",
12769 520.0,
12770 532.0,
12771 ));
12772
12773 let md = to_markdown(&doc).unwrap();
12774 assert!(md.contains(
12775 "Diagram 5\nDistribution of Komnas HAM's YouTube Content (2019- 2020)\n\nAs of 1 December 2021, the channel has 2,290 subscribers"
12776 ));
12777 }
12778
12779 #[test]
12780 fn test_short_caption_label_merges_with_tail_and_year() {
12781 let mut doc = PdfDocument::new("figure-caption.pdf".to_string());
12782 doc.kids.push(make_paragraph("Figure 4", 540.0, 552.0));
12783 doc.kids.push(make_paragraph(
12784 "Komnas HAM's YouTube channel as of 1 December",
12785 520.0,
12786 532.0,
12787 ));
12788 doc.kids.push(make_paragraph("2021", 500.0, 512.0));
12789
12790 let md = to_markdown(&doc).unwrap();
12791 assert!(md.contains("Figure 4\nKomnas HAM's YouTube channel as of 1 December\n2021"));
12792 assert!(!md.contains("\n\n2021"));
12793 }
12794
12795 #[test]
12796 fn test_mid_page_numeric_labels_are_not_dropped_as_page_numbers() {
12797 let mut doc = PdfDocument::new("chart.pdf".to_string());
12798 doc.kids.push(make_paragraph("Figure 1", 760.0, 772.0));
12799 doc.kids.push(make_paragraph("100", 520.0, 528.0));
12800 doc.kids
12801 .push(make_paragraph("Body text continues here.", 400.0, 412.0));
12802 doc.kids.push(make_paragraph("36", 20.0, 28.0));
12803
12804 let md = to_markdown(&doc).unwrap();
12805 assert!(md.contains("100"));
12806 assert!(!md.lines().any(|line| line.trim() == "36"));
12807 }
12808
12809 #[test]
12810 fn test_semantic_paragraphs_are_not_remerged_in_markdown() {
12811 let mut doc = PdfDocument::new("paragraphs.pdf".to_string());
12812 doc.kids.push(make_paragraph(
12813 "First semantic paragraph ends here.",
12814 520.0,
12815 532.0,
12816 ));
12817 doc.kids.push(make_paragraph(
12818 "Second semantic paragraph starts here.",
12819 500.0,
12820 512.0,
12821 ));
12822
12823 let md = to_markdown(&doc).unwrap();
12824 assert!(md.contains(
12825 "First semantic paragraph ends here.\n\nSecond semantic paragraph starts here."
12826 ));
12827 }
12828
12829 #[test]
12830 fn test_lowercase_semantic_paragraph_continuation_is_merged() {
12831 let mut doc = PdfDocument::new("continuation.pdf".to_string());
12832 doc.kids.push(make_paragraph(
12833 "You can then compare the difference you actually obtained against this null distribution to generate a p value for your difference",
12834 520.0,
12835 532.0,
12836 ));
12837 doc.kids.push(make_paragraph("of interest.", 500.0, 512.0));
12838
12839 let md = to_markdown(&doc).unwrap();
12840 assert!(md.contains(
12841 "You can then compare the difference you actually obtained against this null distribution to generate a p value for your difference of interest."
12842 ));
12843 }
12844
12845 #[test]
12846 fn test_semantic_enumerated_paragraphs_are_not_merged() {
12847 let mut doc = PdfDocument::new("enumerated-paragraphs.pdf".to_string());
12848 doc.kids.push(make_paragraph(
12849 "iii. Looking at cost items, the cost of raw woods procurement will be highest share.",
12850 520.0,
12851 532.0,
12852 ));
12853 doc.kids.push(make_paragraph(
12854 "iv. This business model will be operating cost-oriented not capital cost-oriented.",
12855 500.0,
12856 512.0,
12857 ));
12858
12859 let md = to_markdown(&doc).unwrap();
12860 assert!(md.contains(
12861 "iii. Looking at cost items, the cost of raw woods procurement will be highest share.\n\niv. This business model will be operating cost-oriented not capital cost-oriented."
12862 ));
12863 }
12864
12865 #[test]
12866 fn test_leading_figure_carryover_is_skipped_before_first_numbered_heading() {
12867 let mut doc = PdfDocument::new("leading-figure-carryover.pdf".to_string());
12868 doc.number_of_pages = 1;
12869 doc.kids.push(make_paragraph_at(
12870 72.0,
12871 742.0,
12872 540.0,
12873 756.0,
12874 "Figure 6. Mytella strigata biofouling green mussel farms in Bacoor City, Cavite, Manila Bay",
12875 ));
12876 doc.kids.push(make_heading_at(
12877 72.0,
12878 680.0,
12879 260.0,
12880 696.0,
12881 "5. Natural dispersal",
12882 ));
12883 doc.kids.push(make_paragraph_at(
12884 72.0,
12885 640.0,
12886 540.0,
12887 654.0,
12888 "Dispersal by purely natural means is not included as a pathway of biological invasions.",
12889 ));
12890
12891 let md = to_markdown(&doc).unwrap();
12892 assert!(md.starts_with("# 5. Natural dispersal"));
12893 assert!(!md.contains("Figure 6. Mytella strigata"));
12894 }
12895
12896 #[test]
12897 fn test_list_renderer_strips_duplicate_bullets_and_skips_bullet_only_items() {
12898 let mut doc = PdfDocument::new("bullets.pdf".to_string());
12899 doc.kids.push(make_fallback_list(&[
12900 "• First item",
12901 "•",
12902 "• Second item",
12903 "133",
12904 ]));
12905
12906 let md = to_markdown(&doc).unwrap();
12907 assert!(md.contains("- First item"));
12908 assert!(md.contains("- Second item"));
12909 assert!(!md.contains("- • First item"));
12910 assert!(!md.contains("\n- •\n"));
12911 assert!(!md.contains("\n- 133\n"));
12912 }
12913
12914 #[test]
12915 fn test_list_renderer_merges_wrapped_continuation_items() {
12916 let mut doc = PdfDocument::new("wrapped-list.pdf".to_string());
12917 doc.kids.push(make_fallback_list(&[
12918 "Use a micropipette to add 2 μL of loading dye",
12919 "and down a couple of times to mix the loading dye with the digested DNA.",
12920 "Use a fresh pipet tip for each reaction tube.",
12921 ]));
12922
12923 let md = to_markdown(&doc).unwrap();
12924 assert!(md.contains(
12925 "- Use a micropipette to add 2 μL of loading dye and down a couple of times to mix the loading dye with the digested DNA."
12926 ));
12927 assert!(md.contains("- Use a fresh pipet tip for each reaction tube."));
12928 assert!(!md.contains("\n- and down"));
12929 }
12930
12931 #[test]
12932 fn test_list_renderer_keeps_enumerated_items_separate() {
12933 let mut doc = PdfDocument::new("enumerated-list.pdf".to_string());
12934 doc.kids.push(make_fallback_list(&[
12935 "iii. Looking at cost items, the cost of raw woods procurement will be highest share.",
12936 "iv. This business model will be operating cost-oriented not capital cost-oriented.",
12937 "v. Assumed selling price of wood pellet is $100 per tonne and appropriate.",
12938 ]));
12939
12940 let md = to_markdown(&doc).unwrap();
12941 assert!(md.contains("iii. Looking at cost items, the cost of raw woods procurement will be highest share.\niv. This business model will be operating cost-oriented not capital cost-oriented.\nv. Assumed selling price of wood pellet is $100 per tonne and appropriate."));
12942 assert!(!md.contains("- iii."));
12943 }
12944
12945 #[test]
12946 fn test_postprocess_drops_isolated_single_char_noise_lines() {
12947 let markdown = "# The Data Journey\n\n1\n\nTo get started.\n\no\n\nNOTE: Keep going.\n";
12948 let cleaned = drop_isolated_noise_lines(markdown);
12949 assert!(!cleaned.contains("\n1\n"));
12950 assert!(!cleaned.contains("\no\n"));
12951 assert!(cleaned.contains("To get started."));
12952 assert!(cleaned.contains("NOTE: Keep going."));
12953 }
12954
12955 fn make_two_column_table(rows: &[(&str, &str)]) -> ContentElement {
12956 let mut table_rows = Vec::new();
12957 for (row_number, (left, right)) in rows.iter().enumerate() {
12958 let top = 656.0 - row_number as f64 * 18.0;
12959 let bottom = top - 16.0;
12960 let mut cells = Vec::new();
12961 for (col_number, (text, left_x, right_x)) in
12962 [(*left, 72.0, 220.0), (*right, 220.0, 420.0)]
12963 .into_iter()
12964 .enumerate()
12965 {
12966 let content = if text.is_empty() {
12967 Vec::new()
12968 } else {
12969 vec![TableToken {
12970 base: TextChunk {
12971 value: text.to_string(),
12972 bbox: BoundingBox::new(Some(1), left_x, bottom, right_x, top),
12973 font_name: "Test".to_string(),
12974 font_size: 11.0,
12975 font_weight: 400.0,
12976 italic_angle: 0.0,
12977 font_color: "[0.0]".to_string(),
12978 contrast_ratio: 21.0,
12979 symbol_ends: Vec::new(),
12980 text_format: TextFormat::Normal,
12981 text_type: TextType::Regular,
12982 pdf_layer: PdfLayer::Main,
12983 ocg_visible: true,
12984 index: None,
12985 page_number: Some(1),
12986 level: None,
12987 mcid: None,
12988 },
12989 token_type: TableTokenType::Text,
12990 }]
12991 };
12992 cells.push(TableBorderCell {
12993 bbox: BoundingBox::new(Some(1), left_x, bottom, right_x, top),
12994 index: None,
12995 level: None,
12996 row_number,
12997 col_number,
12998 row_span: 1,
12999 col_span: 1,
13000 content,
13001 contents: vec![],
13002 semantic_type: None,
13003 });
13004 }
13005
13006 table_rows.push(TableBorderRow {
13007 bbox: BoundingBox::new(Some(1), 72.0, bottom, 420.0, top),
13008 index: None,
13009 level: None,
13010 row_number,
13011 cells,
13012 semantic_type: None,
13013 });
13014 }
13015
13016 ContentElement::TableBorder(TableBorder {
13017 bbox: BoundingBox::new(
13018 Some(1),
13019 72.0,
13020 656.0 - rows.len() as f64 * 18.0 - 16.0,
13021 420.0,
13022 656.0,
13023 ),
13024 index: None,
13025 level: Some("1".to_string()),
13026 x_coordinates: vec![72.0, 220.0, 420.0],
13027 x_widths: vec![0.0; 3],
13028 y_coordinates: (0..=rows.len()).map(|i| 656.0 - i as f64 * 18.0).collect(),
13029 y_widths: vec![0.0; rows.len() + 1],
13030 rows: table_rows,
13031 num_rows: rows.len(),
13032 num_columns: 2,
13033 is_bad_table: false,
13034 is_table_transformer: false,
13035 previous_table: None,
13036 next_table: None,
13037 })
13038 }
13039
13040 fn make_chunked_paragraph_line(
13041 segments: &[(&str, f64, f64)],
13042 bottom: f64,
13043 top: f64,
13044 ) -> ContentElement {
13045 let bbox = BoundingBox::new(
13046 Some(1),
13047 segments.first().map(|(_, left, _)| *left).unwrap_or(72.0),
13048 bottom,
13049 segments.last().map(|(_, _, right)| *right).unwrap_or(320.0),
13050 top,
13051 );
13052
13053 let chunks = segments
13054 .iter()
13055 .map(|(text, left, right)| TextChunk {
13056 value: (*text).to_string(),
13057 bbox: BoundingBox::new(Some(1), *left, bottom, *right, top),
13058 font_name: "Lato-Regular".to_string(),
13059 font_size: top - bottom,
13060 font_weight: 400.0,
13061 italic_angle: 0.0,
13062 font_color: "#000000".to_string(),
13063 contrast_ratio: 21.0,
13064 symbol_ends: vec![],
13065 text_format: TextFormat::Normal,
13066 text_type: TextType::Regular,
13067 pdf_layer: PdfLayer::Main,
13068 ocg_visible: true,
13069 index: None,
13070 page_number: Some(1),
13071 level: None,
13072 mcid: None,
13073 })
13074 .collect::<Vec<_>>();
13075
13076 let line = TextLine {
13077 bbox: bbox.clone(),
13078 index: None,
13079 level: None,
13080 font_size: top - bottom,
13081 base_line: bottom + 2.0,
13082 slant_degree: 0.0,
13083 is_hidden_text: false,
13084 text_chunks: chunks,
13085 is_line_start: true,
13086 is_line_end: true,
13087 is_list_line: false,
13088 connected_line_art_label: None,
13089 };
13090 let block = TextBlock {
13091 bbox: bbox.clone(),
13092 index: None,
13093 level: None,
13094 font_size: line.font_size,
13095 base_line: line.base_line,
13096 slant_degree: 0.0,
13097 is_hidden_text: false,
13098 text_lines: vec![line],
13099 has_start_line: true,
13100 has_end_line: true,
13101 text_alignment: None,
13102 };
13103 let column = TextColumn {
13104 bbox: bbox.clone(),
13105 index: None,
13106 level: None,
13107 font_size: block.font_size,
13108 base_line: block.base_line,
13109 slant_degree: 0.0,
13110 is_hidden_text: false,
13111 text_blocks: vec![block],
13112 };
13113
13114 ContentElement::Paragraph(SemanticParagraph {
13115 base: SemanticTextNode {
13116 bbox,
13117 index: None,
13118 level: None,
13119 semantic_type: SemanticType::Paragraph,
13120 correct_semantic_score: None,
13121 columns: vec![column],
13122 font_weight: Some(400.0),
13123 font_size: Some(top - bottom),
13124 text_color: None,
13125 italic_angle: None,
13126 font_name: Some("Lato-Regular".to_string()),
13127 text_format: None,
13128 max_font_size: Some(top - bottom),
13129 background_color: None,
13130 is_hidden_text: false,
13131 },
13132 enclosed_top: false,
13133 enclosed_bottom: false,
13134 indentation: 0,
13135 })
13136 }
13137
13138 fn make_n_column_table(rows: &[Vec<&str>], column_bounds: &[(f64, f64)]) -> ContentElement {
13139 let mut table_rows = Vec::new();
13140 for (row_number, row_values) in rows.iter().enumerate() {
13141 let top = 656.0 - row_number as f64 * 18.0;
13142 let bottom = top - 16.0;
13143 let mut cells = Vec::new();
13144 for (col_number, (left_x, right_x)) in column_bounds.iter().enumerate() {
13145 let text = row_values.get(col_number).copied().unwrap_or("");
13146 let content = if text.is_empty() {
13147 Vec::new()
13148 } else {
13149 vec![TableToken {
13150 base: TextChunk {
13151 value: text.to_string(),
13152 bbox: BoundingBox::new(Some(1), *left_x, bottom, *right_x, top),
13153 font_name: "Test".to_string(),
13154 font_size: 11.0,
13155 font_weight: 400.0,
13156 italic_angle: 0.0,
13157 font_color: "[0.0]".to_string(),
13158 contrast_ratio: 21.0,
13159 symbol_ends: Vec::new(),
13160 text_format: TextFormat::Normal,
13161 text_type: TextType::Regular,
13162 pdf_layer: PdfLayer::Main,
13163 ocg_visible: true,
13164 index: None,
13165 page_number: Some(1),
13166 level: None,
13167 mcid: None,
13168 },
13169 token_type: TableTokenType::Text,
13170 }]
13171 };
13172 cells.push(TableBorderCell {
13173 bbox: BoundingBox::new(Some(1), *left_x, bottom, *right_x, top),
13174 index: None,
13175 level: None,
13176 row_number,
13177 col_number,
13178 row_span: 1,
13179 col_span: 1,
13180 content,
13181 contents: vec![],
13182 semantic_type: None,
13183 });
13184 }
13185
13186 table_rows.push(TableBorderRow {
13187 bbox: BoundingBox::new(
13188 Some(1),
13189 column_bounds.first().map(|(left, _)| *left).unwrap_or(72.0),
13190 bottom,
13191 column_bounds
13192 .last()
13193 .map(|(_, right)| *right)
13194 .unwrap_or(420.0),
13195 top,
13196 ),
13197 index: None,
13198 level: None,
13199 row_number,
13200 cells,
13201 semantic_type: None,
13202 });
13203 }
13204
13205 let left = column_bounds
13206 .first()
13207 .map(|(value, _)| *value)
13208 .unwrap_or(72.0);
13209 let right = column_bounds
13210 .last()
13211 .map(|(_, value)| *value)
13212 .unwrap_or(420.0);
13213 let x_coordinates = std::iter::once(left)
13214 .chain(column_bounds.iter().map(|(_, right)| *right))
13215 .collect::<Vec<_>>();
13216
13217 ContentElement::TableBorder(TableBorder {
13218 bbox: BoundingBox::new(
13219 Some(1),
13220 left,
13221 656.0 - rows.len() as f64 * 18.0 - 16.0,
13222 right,
13223 656.0,
13224 ),
13225 index: None,
13226 level: Some("1".to_string()),
13227 x_coordinates,
13228 x_widths: vec![0.0; column_bounds.len() + 1],
13229 y_coordinates: (0..=rows.len()).map(|i| 656.0 - i as f64 * 18.0).collect(),
13230 y_widths: vec![0.0; rows.len() + 1],
13231 rows: table_rows,
13232 num_rows: rows.len(),
13233 num_columns: column_bounds.len(),
13234 is_bad_table: false,
13235 is_table_transformer: false,
13236 previous_table: None,
13237 next_table: None,
13238 })
13239 }
13240
13241 #[test]
13242 fn test_numeric_two_column_table_is_not_misrendered_as_toc() {
13243 let mut doc = PdfDocument::new("cec-table.pdf".to_string());
13244 doc.number_of_pages = 1;
13245 doc.kids.push(make_two_column_table(&[
13246 ("Mineral or colloid type", "CEC of pure colloid"),
13247 ("", "cmolc/kg"),
13248 ("kaolinite", "10"),
13249 ("illite", "30"),
13250 ]));
13251
13252 let md = to_markdown(&doc).unwrap();
13253 assert!(md.contains("| --- | --- |"));
13254 assert!(md.contains("| kaolinite | 10 |"));
13255 }
13256
13257 #[test]
13258 fn test_single_caption_chart_renderer_skips_documents_with_populated_tables() {
13259 let mut doc = PdfDocument::new("table-with-caption.pdf".to_string());
13260 doc.number_of_pages = 1;
13261 for idx in 0..10 {
13262 let bottom = 720.0 - idx as f64 * 18.0;
13263 doc.kids.push(make_paragraph(
13264 "Explanatory body text that should remain outside the chart-only renderer.",
13265 bottom,
13266 bottom + 10.0,
13267 ));
13268 }
13269 doc.kids.push(make_paragraph(
13270 "Figure 7.2: Kinematic Viscosity of Water at Atmospheric Pressure.",
13271 150.0,
13272 162.0,
13273 ));
13274 doc.kids.push(make_two_column_table(&[
13275 ("Temperature", "Viscosity"),
13276 ("20", "1.004"),
13277 ("25", "0.893"),
13278 ]));
13279
13280 assert!(render_layout_single_caption_chart_document(&doc).is_none());
13281 }
13282
13283 #[test]
13284 fn test_blank_right_column_table_is_not_misrendered_as_toc() {
13285 let mut doc = PdfDocument::new("flocculation-table.pdf".to_string());
13286 doc.number_of_pages = 1;
13287 doc.kids.push(make_two_column_table(&[
13288 (
13289 "Added cation",
13290 "Relative Size & Settling Rates of Floccules",
13291 ),
13292 ("K+", ""),
13293 ("Na+", ""),
13294 ("Ca2+", ""),
13295 ]));
13296
13297 let md = to_markdown(&doc).unwrap();
13298 assert!(md.contains("| Added cation | Relative Size & Settling Rates of Floccules |"));
13299 assert!(md.contains("| K+ | |"));
13300 }
13301
13302 #[test]
13303 fn test_infographic_card_table_renders_as_numbered_item() {
13304 let mut doc = PdfDocument::new("infographic-card.pdf".to_string());
13305 doc.number_of_pages = 1;
13306 doc.kids.push(make_two_column_table(&[
13307 (
13308 "1",
13309 "We're all both consumers and creators of creative work.",
13310 ),
13311 (
13312 "",
13313 "As consumers, we watch movies, listen to music, read books, and more.",
13314 ),
13315 ]));
13316
13317 let md = to_markdown(&doc).unwrap();
13318 assert!(md.contains(
13319 "1. We're all both consumers and creators of creative work. As consumers, we watch movies, listen to music, read books, and more."
13320 ));
13321 assert!(!md.contains("| 1 |"));
13322 }
13323
13324 #[test]
13325 fn test_grouped_header_rows_are_preserved_without_flattening() {
13326 let mut doc = PdfDocument::new("grouped-header.pdf".to_string());
13327 doc.number_of_pages = 1;
13328 doc.kids.push(make_n_column_table(
13329 &[
13330 vec!["Properties", "", "Instruction", "", "", "Alignment", ""],
13331 vec![
13332 "",
13333 "Alpaca-GPT4",
13334 "OpenOrca",
13335 "Synth. Math-Instruct",
13336 "Orca DPO Pairs",
13337 "Ultrafeedback Cleaned",
13338 "Synth. Math-Alignment",
13339 ],
13340 vec![
13341 "Total # Samples",
13342 "52K",
13343 "2.91M",
13344 "126K",
13345 "12.9K",
13346 "60.8K",
13347 "126K",
13348 ],
13349 ],
13350 &[
13351 (72.0, 120.0),
13352 (120.0, 170.0),
13353 (170.0, 220.0),
13354 (220.0, 280.0),
13355 (280.0, 340.0),
13356 (340.0, 410.0),
13357 (410.0, 470.0),
13358 ],
13359 ));
13360
13361 let md = to_markdown(&doc).unwrap();
13362 assert!(md.contains(
13363 "| Properties | Instruction | Instruction | Instruction | Alignment | Alignment | Alignment |"
13364 ));
13365 assert!(md.contains(
13366 "| | Alpaca-GPT4 | OpenOrca | Synth. Math-Instruct | Orca DPO Pairs | Ultrafeedback Cleaned | Synth. Math-Alignment |"
13367 ));
13368 assert!(!md.contains("Instruction OpenOrca"));
13369 assert!(!md.contains("Alignment Ultrafeedback"));
13370 }
13371
13372 #[test]
13373 fn test_top_table_plate_renderer_stops_before_article_body() {
13374 let mut doc = PdfDocument::new("table-plate.pdf".to_string());
13375 doc.number_of_pages = 1;
13376 doc.kids
13377 .push(make_paragraph_at(72.0, 724.0, 200.0, 736.0, "SOLAR 10.7B"));
13378 doc.kids.push(make_paragraph_at(
13379 72.0,
13380 704.0,
13381 220.0,
13382 716.0,
13383 "Training datasets",
13384 ));
13385 doc.kids.push(make_n_column_table(
13386 &[
13387 vec!["Properties", "", "Instruction", "", "", "Alignment", ""],
13388 vec![
13389 "",
13390 "Alpaca-GPT4",
13391 "OpenOrca",
13392 "Synth. Math-Instruct",
13393 "Orca DPO Pairs",
13394 "Ultrafeedback Cleaned",
13395 "Synth. Math-Alignment",
13396 ],
13397 vec![
13398 "Total # Samples",
13399 "52K",
13400 "2.91M",
13401 "126K",
13402 "12.9K",
13403 "60.8K",
13404 "126K",
13405 ],
13406 vec![
13407 "Maximum # Samples Used",
13408 "52K",
13409 "100K",
13410 "52K",
13411 "12.9K",
13412 "60.8K",
13413 "20.1K",
13414 ],
13415 vec!["Open Source", "O", "O", "✗", "O", "O", "✗"],
13416 ],
13417 &[
13418 (78.0, 125.0),
13419 (125.0, 175.0),
13420 (175.0, 225.0),
13421 (225.0, 285.0),
13422 (285.0, 345.0),
13423 (345.0, 415.0),
13424 (415.0, 490.0),
13425 ],
13426 ));
13427 doc.kids.push(make_paragraph_at(
13428 72.0,
13429 500.0,
13430 310.0,
13431 514.0,
13432 "Table 1: Training datasets used for the instruction and alignment tuning stages, respectively.",
13433 ));
13434 doc.kids.push(make_paragraph_at(
13435 286.0,
13436 484.0,
13437 526.0,
13438 498.0,
13439 "Open source indicates whether the dataset is open-sourced.",
13440 ));
13441 doc.kids.push(make_paragraph_at(
13442 72.0,
13443 360.0,
13444 290.0,
13445 388.0,
13446 "Comparison to other up-scaling methods. Unlike Komatsuzaki et al. (2022)...",
13447 ));
13448
13449 let md = to_markdown(&doc).unwrap();
13450 assert!(md.contains("Table 1: Training datasets used for the instruction"));
13451 assert!(md.contains("| Properties | Instruction | Instruction | Instruction | Alignment | Alignment | Alignment |"));
13452 assert!(!md.contains("Comparison to other up-scaling methods"));
13453 }
13454
13455 #[test]
13456 fn test_late_section_boundary_renderer_drops_equation_carryover() {
13457 let mut doc = PdfDocument::new("late-section.pdf".to_string());
13458 doc.number_of_pages = 1;
13459 doc.kids.push(make_paragraph_at(
13460 72.0,
13461 700.0,
13462 540.0,
13463 714.0,
13464 "The horizontal distance traveled by the jet is equal to:",
13465 ));
13466 doc.kids.push(make_paragraph_at(
13467 72.0,
13468 640.0,
13469 540.0,
13470 654.0,
13471 "The vertical position of the jet may be calculated as:",
13472 ));
13473 doc.kids.push(make_paragraph_at(
13474 72.0,
13475 580.0,
13476 260.0,
13477 594.0,
13478 "Rearranging Equation (8) gives:",
13479 ));
13480 doc.kids.push(make_paragraph_at(
13481 72.0,
13482 520.0,
13483 420.0,
13484 534.0,
13485 "Substitution into Equation 7 results in:",
13486 ));
13487 doc.kids.push(make_paragraph_at(
13488 72.0,
13489 460.0,
13490 280.0,
13491 474.0,
13492 "Equations (10) can be rearranged to find Cv:",
13493 ));
13494 doc.kids.push(make_heading_at(
13495 72.0,
13496 350.0,
13497 420.0,
13498 366.0,
13499 "7.2. DETERMINATION OF THE COEFFICIENT OF DISCHARGE",
13500 ));
13501 doc.kids.push(make_paragraph_at(
13502 72.0,
13503 326.0,
13504 380.0,
13505 340.0,
13506 "If C_d is assumed to be constant, then a graph of Q plotted against",
13507 ));
13508 doc.kids.push(make_paragraph_at(
13509 400.0,
13510 326.0,
13511 540.0,
13512 340.0,
13513 "(Equation 6) will be linear, and",
13514 ));
13515 doc.kids.push(make_paragraph_at(
13516 72.0,
13517 310.0,
13518 240.0,
13519 324.0,
13520 "the slope of this graph will be:",
13521 ));
13522 doc.kids.push(make_paragraph_at(
13523 360.0,
13524 36.0,
13525 550.0,
13526 48.0,
13527 "EXPERIMENT #6: ORIFICE AND FREE JET FLOW 53",
13528 ));
13529
13530 let md = to_markdown(&doc).unwrap();
13531 assert!(md.starts_with("# 7.2. DETERMINATION OF THE COEFFICIENT OF DISCHARGE"));
13532 assert!(md.contains(
13533 "If C_d is assumed to be constant, then a graph of Q plotted against (Equation 6) will be linear, and the slope of this graph will be:"
13534 ));
13535 assert!(!md.contains("The horizontal distance traveled by the jet"));
13536 assert!(!md.contains("EXPERIMENT #6"));
13537 }
13538
13539 #[test]
13540 fn test_leading_table_carryover_row_is_trimmed_from_general_renderer() {
13541 let mut doc = PdfDocument::new("carryover-table.pdf".to_string());
13542 doc.number_of_pages = 1;
13543 doc.kids.push(make_n_column_table(
13544 &[
13545 vec![
13546 "Jurisdiction",
13547 "GATS XVII Reservation (1994)",
13548 "Foreign Ownership Permitted",
13549 "Restrictions on Foreign Ownership",
13550 "Foreign Ownership Reporting Requirements",
13551 ],
13552 vec![
13553 "",
13554 "",
13555 "",
13556 "right required to acquire desert lands and continue the prior page",
13557 "",
13558 ],
13559 vec!["Finland", "N", "Y", "Prior approval may be required.", ""],
13560 vec!["France", "N", "Y", "None.", ""],
13561 ],
13562 &[
13563 (72.0, 150.0),
13564 (150.0, 235.0),
13565 (235.0, 330.0),
13566 (330.0, 500.0),
13567 (500.0, 560.0),
13568 ],
13569 ));
13570
13571 let md = to_markdown(&doc).unwrap();
13572 assert!(!md.contains("right required to acquire desert lands"));
13573 assert!(md.contains("| Finland | N | Y | Prior approval may be required. | |"));
13574 }
13575
13576 #[test]
13577 fn test_single_table_report_renderer_promotes_title_and_skips_footer() {
13578 let mut doc = PdfDocument::new("single-table-report.pdf".to_string());
13579 doc.number_of_pages = 1;
13580 doc.kids.push(make_paragraph_at(
13581 140.0,
13582 674.0,
13583 474.0,
13584 688.0,
13585 "Restrictions on Land Ownership by Foreigners in Selected Jurisdictions",
13586 ));
13587 doc.kids.push(make_n_column_table(
13588 &[
13589 vec![
13590 "Jurisdiction",
13591 "GATS XVII Reservation (1994)",
13592 "Foreign Ownership Permitted",
13593 "Restrictions on Foreign Ownership",
13594 "Foreign Ownership Reporting Requirements",
13595 ],
13596 vec![
13597 "",
13598 "",
13599 "",
13600 "right required to acquire desert lands and continue the prior page",
13601 "",
13602 ],
13603 vec![
13604 "Finland",
13605 "N",
13606 "Y",
13607 "Prior approval from the Government of Aland may be required.",
13608 "",
13609 ],
13610 vec!["France", "N", "Y", "None.", ""],
13611 ],
13612 &[
13613 (72.0, 150.0),
13614 (150.0, 235.0),
13615 (235.0, 330.0),
13616 (330.0, 500.0),
13617 (500.0, 560.0),
13618 ],
13619 ));
13620 doc.kids.push(make_paragraph_at(
13621 350.0,
13622 36.0,
13623 548.0,
13624 48.0,
13625 "The Law Library of Congress 7",
13626 ));
13627
13628 let md = to_markdown(&doc).unwrap();
13629 assert!(md.starts_with(
13630 "# Restrictions on Land Ownership by Foreigners in Selected Jurisdictions"
13631 ));
13632 assert!(!md.contains("right required to acquire desert lands"));
13633 assert!(!md.contains("The Law Library of Congress 7"));
13634 assert!(md.contains(
13635 "| Finland | N | Y | Prior approval from the Government of Aland may be required. | |"
13636 ));
13637 }
13638
13639 #[test]
13640 fn test_hyphenated_table_title_continuation_renders_as_heading() {
13641 let mut doc = PdfDocument::new("hyphenated-table-title.pdf".to_string());
13642 doc.number_of_pages = 1;
13643 doc.kids.push(make_paragraph_at(
13644 72.0,
13645 724.0,
13646 520.0,
13647 738.0,
13648 "With this in mind, here we have the 7 key competence areas selected to form a part of Eco-",
13649 ));
13650 doc.kids.push(make_paragraph_at(
13651 72.0,
13652 704.0,
13653 260.0,
13654 718.0,
13655 "Circle's Competence Framework:",
13656 ));
13657 doc.kids.push(make_n_column_table(
13658 &[
13659 vec!["Eco-Circle Competence Framework"],
13660 vec!["#1: The 3 Rs: Recycle-Reuse-Reduce"],
13661 vec!["#2: Lifecycle of Circular Economy"],
13662 ],
13663 &[(140.0, 460.0)],
13664 ));
13665
13666 let md = to_markdown(&doc).unwrap();
13667 assert!(md.contains("# Circle's Competence Framework:"), "{md}");
13668 }
13669
13670 #[test]
13671 fn test_duplicate_table_header_heading_is_demoted() {
13672 let mut doc = PdfDocument::new("duplicate-table-header-heading.pdf".to_string());
13673 doc.number_of_pages = 1;
13674 doc.kids
13675 .push(make_heading("MOHAVE COMMUNITY COLLEGE BIO181"));
13676 doc.kids.push(make_n_column_table(
13677 &[
13678 vec![
13679 "",
13680 "Saccharometer",
13681 "DI Water",
13682 "Glucose Solution",
13683 "Yeast Suspension",
13684 ],
13685 vec!["1", "", "8 ml", "6 ml", "0 ml"],
13686 vec!["2", "", "12 ml", "0 ml", "2 ml"],
13687 vec!["3", "", "6 ml", "6 ml", "2 ml"],
13688 ],
13689 &[
13690 (72.0, 110.0),
13691 (110.0, 210.0),
13692 (210.0, 300.0),
13693 (300.0, 430.0),
13694 (430.0, 540.0),
13695 ],
13696 ));
13697 doc.kids.push(make_heading_at(
13698 72.0,
13699 92.0,
13700 390.0,
13701 108.0,
13702 "Saccharometer DI Water Glucose Solution Yeast Suspension",
13703 ));
13704 doc.kids
13705 .push(make_paragraph_at(72.0, 72.0, 120.0, 88.0, "below"));
13706 doc.kids
13707 .push(make_paragraph_at(72.0, 56.0, 240.0, 72.0, "1 16 ml 12 ml"));
13708 doc.kids
13709 .push(make_paragraph_at(296.0, 56.0, 340.0, 72.0, "0 ml"));
13710
13711 let md = to_markdown(&doc).unwrap();
13712 assert!(
13713 md.contains("Saccharometer DI Water Glucose Solution Yeast Suspension"),
13714 "{md}"
13715 );
13716 assert!(
13717 !md.contains("# Saccharometer DI Water Glucose Solution Yeast Suspension"),
13718 "{md}"
13719 );
13720 }
13721
13722 #[test]
13723 fn test_geometric_panel_headers_are_promoted_into_table() {
13724 let mut doc = PdfDocument::new("ai-pack-panel.pdf".to_string());
13725 doc.kids.push(make_chunked_paragraph_line(
13726 &[("OCR", 220.0, 250.0)],
13727 720.0,
13728 732.0,
13729 ));
13730 doc.kids.push(make_chunked_paragraph_line(
13731 &[("Recommendation", 430.0, 540.0)],
13732 720.0,
13733 732.0,
13734 ));
13735 doc.kids.push(make_chunked_paragraph_line(
13736 &[("Product semantic search", 660.0, 860.0)],
13737 720.0,
13738 732.0,
13739 ));
13740 doc.kids.push(make_chunked_paragraph_line(
13741 &[("Pack", 72.0, 110.0)],
13742 684.0,
13743 696.0,
13744 ));
13745 doc.kids.push(make_chunked_paragraph_line(
13746 &[("A solution that recognizes characters", 140.0, 340.0)],
13747 684.0,
13748 696.0,
13749 ));
13750 doc.kids.push(make_chunked_paragraph_line(
13751 &[("A solution that recommends the best products", 390.0, 620.0)],
13752 684.0,
13753 696.0,
13754 ));
13755 doc.kids.push(make_chunked_paragraph_line(
13756 &[("A solution that enables semantic search", 650.0, 900.0)],
13757 684.0,
13758 696.0,
13759 ));
13760 doc.kids.push(make_n_column_table(
13761 &[
13762 vec![
13763 "Achieved 1st place in the OCR World Competition",
13764 "Team with specialists and technologies",
13765 "Creation of the first natural language evaluation",
13766 ],
13767 vec![
13768 "The team includes specialists who have",
13769 "received Kaggle's Gold Medal recommendation",
13770 "system in Korean (KLUE)",
13771 ],
13772 vec![
13773 "presented 14 papers in renowned AI conferences",
13774 "top-tier recommendation",
13775 "Shopee subject",
13776 ],
13777 ],
13778 &[(120.0, 360.0), (360.0, 630.0), (630.0, 910.0)],
13779 ));
13780 doc.kids.push(make_chunked_paragraph_line(
13781 &[("models", 430.0, 490.0)],
13782 552.0,
13783 564.0,
13784 ));
13785
13786 let md = to_markdown(&doc).unwrap();
13787 assert!(md.contains("| Pack | OCR | Recommendation | Product semantic search |"));
13788 assert!(md.contains("| A solution that recognizes characters | A solution that recommends the best products | A solution that enables semantic search |"));
13789 assert!(md.contains(
13790 "received Kaggle's Gold Medal recommendation top-tier recommendation models"
13791 ));
13792 }
13793
13794 #[test]
13795 fn test_embedded_stub_header_is_promoted_from_first_table_column() {
13796 let mut doc = PdfDocument::new("embedded-stub-header.pdf".to_string());
13797 doc.kids.push(make_chunked_paragraph_line(
13798 &[("OCR", 220.0, 250.0)],
13799 720.0,
13800 732.0,
13801 ));
13802 doc.kids.push(make_chunked_paragraph_line(
13803 &[("Recommendation", 430.0, 540.0)],
13804 720.0,
13805 732.0,
13806 ));
13807 doc.kids.push(make_chunked_paragraph_line(
13808 &[("Product semantic search", 660.0, 860.0)],
13809 720.0,
13810 732.0,
13811 ));
13812 doc.kids.push(make_n_column_table(
13813 &[
13814 vec![
13815 "Pack",
13816 "A solution that recognizes characters in an image and extracts necessary information",
13817 "A solution that recommends the best products and contents",
13818 "A solution that enables semantic search and organizes key information",
13819 ],
13820 vec![
13821 "Application",
13822 "Applicable to all fields that require text extraction",
13823 "Applicable to all fields that use any form of recommendation",
13824 "Applicable to all fields that deal with unstructured data",
13825 ],
13826 vec![
13827 "Highlight",
13828 "Achieved 1st place in the OCR World Competition",
13829 "Received Kaggle's Gold Medal recommendation",
13830 "Creation of the first natural language evaluation system in Korean",
13831 ],
13832 ],
13833 &[
13834 (72.0, 120.0),
13835 (120.0, 360.0),
13836 (360.0, 630.0),
13837 (630.0, 910.0),
13838 ],
13839 ));
13840
13841 let md = to_markdown(&doc).unwrap();
13842 assert!(md.contains("| Pack | OCR | Recommendation | Product semantic search |"));
13843 assert!(
13844 md.contains("| Application | Applicable to all fields that require text extraction |")
13845 );
13846 assert!(md.contains("| Highlight | Achieved 1st place in the OCR World Competition |"));
13847 assert!(!md.contains("OCR\n\nRecommendation\n\nProduct semantic search"));
13848 }
13849
13850 #[test]
13851 fn test_geometric_chunk_alignment_splits_header_line_into_columns() {
13852 let line = make_chunked_paragraph_line(
13853 &[
13854 ("Properties", 72.0, 145.0),
13855 ("Instruction", 180.0, 255.0),
13856 ("Alignment", 480.0, 545.0),
13857 ],
13858 720.0,
13859 732.0,
13860 );
13861 let chunk_lines = extract_chunk_lines(&line);
13862 let fragments = split_line_into_slot_fragments(
13863 &chunk_lines[0],
13864 &[
13865 (72.0, 170.0),
13866 (170.0, 280.0),
13867 (280.0, 380.0),
13868 (380.0, 480.0),
13869 (480.0, 600.0),
13870 (600.0, 720.0),
13871 (720.0, 850.0),
13872 ],
13873 );
13874
13875 assert_eq!(fragments.len(), 3);
13876 assert_eq!(fragments[0].slot_idx, 0);
13877 assert_eq!(fragments[0].text, "Properties");
13878 assert_eq!(fragments[1].slot_idx, 1);
13879 assert_eq!(fragments[1].text, "Instruction");
13880 assert_eq!(fragments[2].slot_idx, 4);
13881 assert_eq!(fragments[2].text, "Alignment");
13882 }
13883
13884 #[test]
13885 fn test_merge_tables_across_heading() {
13886 let input = "some text\n\n\
13887 | Area | Competence |\n\
13888 | --- | --- |\n\
13889 | Row1 | Val1 |\n\
13890 | Row2 | Val2 |\n\
13891 \n\
13892 # Heading Between\n\
13893 \n\
13894 | Row3 | Val3 |\n\
13895 | --- | --- |\n\
13896 \n\
13897 more text\n";
13898 let result = merge_adjacent_pipe_tables(input);
13899 assert!(
13901 result.contains("| Heading Between |"),
13902 "Heading should be in pipe row: {}",
13903 result
13904 );
13905 assert!(
13907 !result.contains("# Heading Between"),
13908 "Heading marker should be removed: {}",
13909 result
13910 );
13911 assert!(
13913 result.contains("| Row3 |") || result.contains("Row3"),
13914 "Row3 should exist: {}",
13915 result
13916 );
13917 }
13918
13919 #[test]
13920 fn test_merge_tables_does_not_cross_distinct_headers() {
13921 let input = "| Model | Score |\n\
13922 | --- | --- |\n\
13923 | A | 1 |\n\
13924 \n\
13925 Table 6: Performance comparison amongst the merge candidates.\n\
13926 \n\
13927 | Model | Method | Score |\n\
13928 | --- | --- | --- |\n\
13929 | B | Avg | 2 |\n";
13930 let result = merge_adjacent_pipe_tables(input);
13931
13932 assert!(result.contains("Table 6: Performance comparison amongst the merge candidates."));
13933 assert!(result.contains("| Model | Score |"));
13934 assert!(result.contains("| Model | Method | Score |"));
13935 assert!(
13936 !result.contains("| Table 6: Performance comparison amongst the merge candidates. |")
13937 );
13938 }
13939
13940 #[test]
13941 #[ignore = "v0.3.0: TOC/chart markdown expectations drifted; restore in follow-up"]
13942 fn test_normalize_chart_like_markdown_extracts_series_tables() {
13943 let input = "Figure 1.7. Non-citizen population in Malaysia (in thousands) 3,323 3,500 3,288 3,230 3,140 2,907 3,000 2,693 2,500 2,000 1,500 1,000 500 0\n\n\
13944 2016 2017 2018 2019 2020 2021 Source: Department of Statistics, Malaysia (2022). Figure for 2021 is an estimate.\n\n\
13945 ASEAN Migration Outlook 19\n";
13946
13947 let normalized = normalize_chart_like_markdown(input);
13948 assert!(
13949 normalized.contains("## Figure 1.7. Non-citizen population in Malaysia (in thousands)")
13950 );
13951 assert!(normalized.contains("| 2016 | 3,323 |"));
13952 assert!(normalized.contains("| 2021 | 2,693 |"));
13953 assert!(normalized.contains(
13954 "*Source: Department of Statistics, Malaysia (2022). Figure for 2021 is an estimate.*"
13955 ));
13956 assert!(!normalized.contains("ASEAN Migration Outlook 19"));
13957 }
13958
13959 #[test]
13960 fn test_normalize_chart_like_markdown_promotes_structural_captions() {
13961 let input = "Figure 5.1 Mr. Bologna Jun-r as Kalim Azack in Aladdin, or\n\n\
13962 The Wonderful Lamp.\n\n\
13963 Body paragraph.\n";
13964
13965 let normalized = normalize_chart_like_markdown(input);
13966 assert!(normalized.contains(
13967 "Figure 5.1 Mr. Bologna Jun-r as Kalim Azack in Aladdin, or The Wonderful Lamp"
13968 ));
13969 assert!(!normalized.contains("## Figure 5.1"));
13970 assert!(normalized.contains("Body paragraph."));
13971 }
13972
13973 #[test]
13974 fn test_normalize_chart_like_markdown_reconstructs_header_pair_chart_table() {
13975 let input = "Figure 4.8. Domestic Wood Pellets Production\n\n\
13976 | 8 | 800 200 | 126 2014 | 120 2015 | 120 2016 | 127 2017 | 131 2018 | 147 2019 |\n\
13977 | --- | --- | --- | --- | --- | --- | --- | --- |\n\n\
13978 Source: Forestry Agency, Ministry of Agriculture, Forestry and Fishery (MAFF), 2020.\n";
13979
13980 let normalized = normalize_chart_like_markdown(input);
13981 assert!(normalized.contains("Figure 4.8. Domestic Wood Pellets Production"));
13982 assert!(!normalized.contains("# Figure 4.8"));
13983 assert!(normalized.contains("| Year | Domestic Wood Pellets Production |"));
13984 assert!(normalized.contains("| 2014 | 126 |"));
13985 assert!(normalized.contains("| 2019 | 147 |"));
13986 assert!(!normalized.contains("| 8 | 800 200 |"));
13987 }
13988
13989 #[test]
13990 fn test_normalize_chart_like_markdown_drops_numeric_axis_artifact_table() {
13991 let input = "| 31 1 0 2 23 2 2 2 0 5 10 15 20 25 30 35 Event Celebration Information Videograph 2019 2020 |\n\
13992 | --- |\n\n\
13993 Distribution of Komnas HAM's YouTube Content (2019-2020)\n";
13994
13995 let normalized = normalize_chart_like_markdown(input);
13996 assert!(!normalized.contains("| --- |"));
13997 assert!(normalized.contains("Distribution of Komnas HAM's YouTube Content (2019-2020)"));
13998 }
13999
14000 #[test]
14001 fn test_normalize_chart_like_markdown_drops_url_fragment_table() {
14002 let input = "## Figure 6 DPN Argentina Content: World Health Day Celebration\n\n\
14003 | na/status/1379765916259483648 |\n\
14004 | --- |\n\n\
14005 98 DPN Argentina, accessed on 5 December 2021.\n";
14006
14007 let normalized = normalize_chart_like_markdown(input);
14008 assert!(!normalized.contains("/status/1379765916259483648 |"));
14009 assert!(normalized.contains("98 DPN Argentina, accessed on 5 December 2021."));
14010 }
14011
14012 #[test]
14013 fn test_normalize_chart_like_markdown_drops_sparse_table_before_caption() {
14014 let input = "What’s unique about the growth of Alligator Gars is their fast growth.\n\n\
14015 | in | cm | | Length | of | Gar | Fish | Age |\n\
14016 | --- | --- | --- | --- | --- | --- | --- | --- |\n\
14017 | 120) | 300 | | | | | | |\n\
14018 | 100+ | 250 | | | | | | |\n\
14019 | 80+ | 200 | | | | | | |\n\
14020 | 20. | 50 | G | | | | | Vi |\n\
14021 | 0 | 0 | | | | | | |\n\
14022 | | 0 | 10 | 30 | | 40 | 50 | 60 |\n\n\
14023 Figure 8.6: Growth in length of Alligator Gar in Texas.\n";
14024
14025 let normalized = normalize_chart_like_markdown(input);
14026 assert!(!normalized.contains("| in | cm |"));
14027 assert!(normalized.contains("Figure 8.6: Growth in length of Alligator Gar in Texas."));
14028 }
14029
14030 #[test]
14031 fn test_normalize_chart_like_markdown_trims_large_top_table_plate() {
14032 let input = "| A | B | C | D | E | F | G | H |\n\
14033 | --- | --- | --- | --- | --- | --- | --- | --- |\n\
14034 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14035 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14036 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14037 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14038 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14039 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14040 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\
14041 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |\n\n\
14042 Table 2: Evaluation results for SOLAR 10.7B and SOLAR 10.7B-Instruct along with other top-performing models in the paper.\n\n\
14043 # 4.2 Main Results\n\n\
14044 The surrounding prose should be dropped.\n";
14045
14046 let normalized = normalize_chart_like_markdown(input);
14047 assert!(normalized.starts_with("| A | B | C | D | E | F | G | H |"));
14048 assert!(!normalized.contains("Table 2:"));
14049 assert!(!normalized.contains("4.2 Main Results"));
14050 assert!(!normalized.contains("surrounding prose"));
14051 }
14052}