1#![allow(clippy::collapsible_match)]
6
7#[cfg(feature = "python")]
32pub mod python;
33
34pub mod adobe_korea1;
35mod bidi;
36mod bidi_mirroring;
37pub mod detector;
38pub mod extractor;
39mod form_bbox_repair;
40pub mod glyph_names;
41mod mac_glyph_order;
42pub mod markdown;
43mod overlong_numerals;
44pub mod process_mode;
45pub mod structure_tree;
46pub mod tables;
47mod text_quality;
48pub mod text_utils;
49pub mod tounicode;
50pub mod types;
51pub mod vision;
52mod xref_repair;
53
54pub use detector::{
55 detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
56 detect_pdf_type_with_config, DetectionConfig, PdfType, PdfTypeResult, ScanStrategy,
57};
58pub use extractor::geometry::PageRotation;
59pub use extractor::{
60 extract_text, extract_text_with_positions, extract_text_with_positions_and_rotations_mem,
61 extract_text_with_positions_and_rotations_mem_in_frame,
62 extract_text_with_positions_and_rotations_mem_with_options, extract_text_with_positions_mem,
63 extract_text_with_positions_mem_in_frame, extract_text_with_positions_mem_with_options,
64 extract_text_with_positions_pages, extract_text_with_positions_pages_with_password,
65 PositionFrame, PositionOptions,
66};
67pub use markdown::{
68 to_markdown, to_markdown_from_items, to_markdown_from_items_with_rects,
69 to_markdown_from_items_with_rects_and_page_count, MarkdownOptions, MarkdownProfile,
70};
71pub use process_mode::ProcessMode;
72pub use types::{BoldSource, LayoutComplexity, PdfLine, PdfRect, TextItem};
73
74use lopdf::Document;
75use std::collections::{BTreeMap, HashMap, HashSet};
76use std::path::Path;
77use text_quality::{
78 analyze_text_quality, detect_encoding_issues, is_cid_garbage, is_garbage_text,
79 region_items_have_decoding_issue,
80};
81use tounicode::FontCMaps;
82
83#[cfg(not(target_arch = "wasm32"))]
84struct ProcessingTimer(std::time::Instant);
85
86#[cfg(target_arch = "wasm32")]
87struct ProcessingTimer;
88
89impl ProcessingTimer {
90 fn start() -> Self {
91 #[cfg(not(target_arch = "wasm32"))]
92 {
93 Self(std::time::Instant::now())
94 }
95
96 #[cfg(target_arch = "wasm32")]
97 {
98 Self
99 }
100 }
101
102 fn elapsed_ms(&self) -> u64 {
103 #[cfg(not(target_arch = "wasm32"))]
104 {
105 self.0.elapsed().as_millis() as u64
106 }
107
108 #[cfg(target_arch = "wasm32")]
109 {
110 0
113 }
114 }
115}
116
117pub const OCR_REASON_SUSPECTED_GARBLED_TEXT: &str = "suspected_garbled_text";
120
121pub const OCR_REASON_SCANNED: &str = "scanned";
124
125pub const OCR_REASON_NO_TEXT: &str = "no_text";
128
129pub const OCR_REASON_VECTOR_TEXT: &str = "vector_text";
132
133pub const OCR_REASON_INVISIBLE_TEXT_LAYER: &str = "invisible_text_layer";
139
140#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PageOcrReasons {
147 pub page: u32,
149 pub reasons: Vec<String>,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct FontCMapGaps {
167 pub font: String,
170 pub codes: u32,
173 pub interpolated: u32,
176 pub unmapped: u32,
179}
180
181#[derive(Debug)]
183pub struct PdfProcessResult {
184 pub pdf_type: PdfType,
186 pub markdown: Option<String>,
188 pub page_count: u32,
190 pub processing_time_ms: u64,
192 pub pages_needing_ocr: Vec<u32>,
194 pub ocr_reasons_by_page: Vec<PageOcrReasons>,
196 pub title: Option<String>,
202 pub author: Option<String>,
204 pub subject: Option<String>,
206 pub keywords: Option<String>,
208 pub creator: Option<String>,
211 pub producer: Option<String>,
214 pub creation_date: Option<String>,
218 pub mod_date: Option<String>,
221 pub confidence: f32,
223 pub layout: LayoutComplexity,
225 pub has_encoding_issues: bool,
228 pub cmap_gaps: Vec<FontCMapGaps>,
235}
236
237#[derive(Clone)]
253pub struct PdfOptions {
254 pub mode: ProcessMode,
256 pub detection: DetectionConfig,
258 pub markdown: MarkdownOptions,
260 pub page_filter: Option<HashSet<u32>>,
262 pub password: Option<String>,
265}
266
267impl std::fmt::Debug for PdfOptions {
270 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271 f.debug_struct("PdfOptions")
272 .field("mode", &self.mode)
273 .field("detection", &self.detection)
274 .field("markdown", &self.markdown)
275 .field("page_filter", &self.page_filter)
276 .field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
277 .finish()
278 }
279}
280
281impl Default for PdfOptions {
282 fn default() -> Self {
283 Self {
284 mode: ProcessMode::Full,
285 detection: DetectionConfig::default(),
286 markdown: MarkdownOptions::default(),
287 page_filter: None,
288 password: None,
289 }
290 }
291}
292
293impl PdfOptions {
294 pub fn new() -> Self {
296 Self::default()
297 }
298
299 pub fn detect_only() -> Self {
301 Self {
302 mode: ProcessMode::DetectOnly,
303 ..Self::default()
304 }
305 }
306
307 pub fn mode(mut self, mode: ProcessMode) -> Self {
309 self.mode = mode;
310 self
311 }
312
313 pub fn detection(mut self, config: DetectionConfig) -> Self {
315 self.detection = config;
316 self
317 }
318
319 pub fn markdown(mut self, options: MarkdownOptions) -> Self {
321 self.markdown = options;
322 self
323 }
324
325 pub fn pages(mut self, pages: impl IntoIterator<Item = u32>) -> Self {
327 self.page_filter = Some(pages.into_iter().collect());
328 self
329 }
330
331 pub fn password(mut self, password: impl Into<String>) -> Self {
333 self.password = Some(password.into());
334 self
335 }
336}
337
338pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
347 process_pdf_with_options(path, PdfOptions::new())
348}
349
350pub fn detect_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
354 process_pdf_with_options(path, PdfOptions::detect_only())
355}
356
357pub fn process_pdf_with_options<P: AsRef<Path>>(
361 path: P,
362 options: PdfOptions,
363) -> Result<PdfProcessResult, PdfError> {
364 let start = ProcessingTimer::start();
365 validate_pdf_file(&path)?;
366
367 let (doc, page_count) =
369 load_document_from_path_with_password(&path, options.password.as_deref())?;
370
371 process_document(doc, page_count, options, start)
372}
373
374pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
376 process_pdf_mem_with_options(buffer, PdfOptions::new())
377}
378
379pub fn detect_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
381 process_pdf_mem_with_options(buffer, PdfOptions::detect_only())
382}
383
384pub fn process_pdf_mem_with_options(
388 buffer: &[u8],
389 options: PdfOptions,
390) -> Result<PdfProcessResult, PdfError> {
391 let start = ProcessingTimer::start();
392 validate_pdf_bytes(buffer)?;
393
394 let (doc, page_count) =
395 load_document_from_mem_with_password(buffer, options.password.as_deref())?;
396
397 process_document(doc, page_count, options, start)
398}
399
400#[deprecated(since = "0.2.0", note = "Use process_pdf_with_options instead")]
406pub fn process_pdf_with_config<P: AsRef<Path>>(
407 path: P,
408 config: DetectionConfig,
409 markdown_options: MarkdownOptions,
410) -> Result<PdfProcessResult, PdfError> {
411 process_pdf_with_options(
412 path,
413 PdfOptions::new()
414 .detection(config)
415 .markdown(markdown_options),
416 )
417}
418
419#[deprecated(since = "0.2.0", note = "Use process_pdf_with_options instead")]
421pub fn process_pdf_with_config_pages<P: AsRef<Path>>(
422 path: P,
423 config: DetectionConfig,
424 markdown_options: MarkdownOptions,
425 page_filter: Option<&HashSet<u32>>,
426) -> Result<PdfProcessResult, PdfError> {
427 let mut opts = PdfOptions::new()
428 .detection(config)
429 .markdown(markdown_options);
430 opts.page_filter = page_filter.cloned();
431 process_pdf_with_options(path, opts)
432}
433
434#[deprecated(since = "0.2.0", note = "Use process_pdf_mem_with_options instead")]
436pub fn process_pdf_mem_with_config(
437 buffer: &[u8],
438 config: DetectionConfig,
439 markdown_options: MarkdownOptions,
440) -> Result<PdfProcessResult, PdfError> {
441 process_pdf_mem_with_options(
442 buffer,
443 PdfOptions::new()
444 .detection(config)
445 .markdown(markdown_options),
446 )
447}
448
449#[derive(Debug)]
455pub struct PdfClassification {
456 pub pdf_type: PdfType,
458 pub page_count: u32,
460 pub pages_needing_ocr: Vec<u32>,
462 pub confidence: f32,
464}
465
466pub fn classify_pdf_mem(buffer: &[u8]) -> Result<PdfClassification, PdfError> {
469 validate_pdf_bytes(buffer)?;
470 let (doc, page_count) = load_document_from_mem(buffer)?;
471 let detection = detector::detect_from_document(&doc, page_count, &DetectionConfig::default())?;
472 Ok(PdfClassification {
473 pdf_type: detection.pdf_type,
474 page_count,
475 pages_needing_ocr: detection.pages_needing_ocr.iter().map(|&p| p - 1).collect(),
477 confidence: detection.confidence,
478 })
479}
480
481pub fn widen_degenerate_form_bboxes_mem(buffer: &[u8]) -> Result<Option<Vec<u8>>, PdfError> {
505 validate_pdf_bytes(buffer)?;
506 let (mut doc, _page_count, repairs) = match load_document_from_mem_with_repairs(buffer, None) {
507 Ok(loaded) => loaded,
508 Err(PdfError::Encrypted) => return Ok(None),
509 Err(error) => return Err(error),
510 };
511 if !repairs.repaired_forms() || doc.is_encrypted() || doc.encryption_state.is_some() {
512 return Ok(None);
513 }
514 Ok(form_bbox_repair::serialize_for_rendering(&mut doc))
515}
516
517#[derive(Debug)]
523pub struct PageMarkdown {
524 pub page: u32,
526 pub markdown: String,
528 pub needs_ocr: bool,
531 pub ocr_reason: Option<String>,
533}
534
535#[derive(Debug)]
537pub struct PagesExtractionResult {
538 pub pages: Vec<PageMarkdown>,
540 pub pages_with_tables: Vec<u32>,
542 pub pages_with_columns: Vec<u32>,
544 pub pages_needing_ocr: Vec<u32>,
546 pub ocr_reasons_by_page: Vec<PageOcrReasons>,
548 pub is_complex: bool,
550}
551
552pub(crate) struct InternalPagesExtraction {
553 pub(crate) result: PagesExtractionResult,
554 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
555 pub(crate) page_count: u32,
556 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
557 pub(crate) supplemental_ocr_regions: BTreeMap<u32, Vec<PdfRect>>,
558 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
562 pub(crate) render_bytes: Option<Vec<u8>>,
563}
564
565pub fn extract_pages_markdown_mem(
584 buffer: &[u8],
585 pages: Option<&[u32]>,
586) -> Result<PagesExtractionResult, PdfError> {
587 extract_pages_markdown_mem_impl(
588 buffer,
589 pages,
590 None,
591 &MarkdownOptions::default(),
592 false,
593 false,
594 false,
595 )
596 .map(|extraction| extraction.result)
597}
598
599#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
600pub(crate) fn extract_pages_markdown_mem_for_ocr(
604 buffer: &[u8],
605 pages: Option<&[u32]>,
606 password: Option<&str>,
607 markdown_options: &MarkdownOptions,
608 render_repairs: bool,
609) -> Result<InternalPagesExtraction, PdfError> {
610 extract_pages_markdown_mem_impl(
611 buffer,
612 pages,
613 password,
614 markdown_options,
615 markdown_options.strip_headers_footers,
616 true,
617 render_repairs,
618 )
619}
620
621fn extract_pages_markdown_mem_impl(
622 buffer: &[u8],
623 pages: Option<&[u32]>,
624 password: Option<&str>,
625 markdown_options: &MarkdownOptions,
626 strip_repeated_headers_footers: bool,
627 preserve_ocr_candidates: bool,
628 render_repairs: bool,
629) -> Result<InternalPagesExtraction, PdfError> {
630 validate_pdf_bytes(buffer)?;
631 let (doc, page_count, repairs) = load_document_from_mem_with_repairs(buffer, password)?;
632 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
633 let mut doc = doc;
634 #[cfg(not(all(feature = "ocr", not(target_arch = "wasm32"))))]
635 let _ = (repairs, render_repairs);
636 let font_cmaps = FontCMaps::from_doc(&doc);
637
638 let required_pages: Option<HashSet<u32>> = pages.map(|pages| {
642 pages
643 .iter()
644 .filter_map(|page| page.checked_add(1))
645 .collect()
646 });
647 let ((all_items, all_rects, all_lines), page_thresholds, gid_pages, _page_rotations, _) =
648 if let Some(required_pages) = required_pages.as_ref() {
649 extractor::extract_positioned_text_for_document_analysis(
650 &doc,
651 &font_cmaps,
652 required_pages,
653 )?
654 } else {
655 extractor::extract_positioned_text_from_doc(&doc, &font_cmaps, None)?
656 };
657 let text_quality = analyze_text_quality(&all_items);
658
659 let (filtered_items, removed_page_number_pages, page_number_removal_mask) =
663 extractor::filter_markdown_page_numbers_with_removed_pages(all_items.clone(), page_count);
664
665 let chart_regions = markdown::chart_regions_by_page(&all_items, &all_rects, &all_lines);
668 let complexity = compute_layout_complexity_with_chart_regions(
669 &all_items,
670 &filtered_items,
671 &all_rects,
672 &all_lines,
673 &chart_regions,
674 );
675
676 let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
678 let repeated_header_footer_items = if strip_repeated_headers_footers {
679 repeated_header_footer_item_keys(&all_items, &page_thresholds, &chart_regions, page_count)
680 } else {
681 HashSet::new()
682 };
683
684 let all_pages: Vec<u32>;
686 let pages_slice: &[u32] = match pages {
687 Some(p) => p,
688 None => {
689 all_pages = (0..page_count).collect();
690 &all_pages
691 }
692 };
693
694 let mut results = Vec::with_capacity(pages_slice.len());
695 let mut pages_needing_ocr = Vec::new();
696 let mut ocr_reasons_by_page = BTreeMap::new();
697 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
698 let mut supplemental_ocr_regions = BTreeMap::new();
699 let lopdf_pages = doc.get_pages();
700
701 for &page_0idx in pages_slice {
702 if page_0idx >= page_count {
704 pages_needing_ocr.push(page_0idx + 1);
705 results.push(PageMarkdown {
706 page: page_0idx,
707 markdown: String::new(),
708 needs_ocr: true,
709 ocr_reason: None,
710 });
711 continue;
712 }
713
714 let page_1idx = page_0idx + 1;
715
716 let (page_items, page_number_removal_mask): (Vec<TextItem>, Vec<bool>) = all_items
718 .iter()
719 .zip(&page_number_removal_mask)
720 .filter(|(item, _)| {
721 item.page == page_1idx
722 && !repeated_header_footer_items.contains(&HeaderFooterItemKey::from(*item))
723 })
724 .map(|(item, remove)| (item.clone(), *remove))
725 .unzip();
726
727 let page_rects: Vec<PdfRect> = all_rects
728 .iter()
729 .filter(|r| r.page == page_1idx)
730 .cloned()
731 .collect();
732
733 let page_lines: Vec<types::PdfLine> = all_lines
734 .iter()
735 .filter(|l| l.page == page_1idx)
736 .cloned()
737 .collect();
738
739 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
740 {
741 let image_regions: Vec<PdfRect> = page_items
742 .iter()
743 .filter_map(supplemental_ocr_image_region)
744 .collect();
745 if !image_regions.is_empty() {
746 supplemental_ocr_regions.insert(page_1idx, image_regions);
747 }
748 }
749
750 let has_gid = gid_pages.contains(&page_1idx);
751 let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
752
753 let signals = lopdf_pages
771 .get(&page_1idx)
772 .map(|&page_id| detector::page_ocr_signals(&doc, page_id))
773 .unwrap_or_default();
774 let has_template_image = signals.template_image_needs_ocr;
775 let has_vector_text = signals.has_vector_text;
776 let has_invisible_text_layer = signals.has_invisible_text_layer;
777
778 let options = MarkdownOptions {
780 base_font_size: Some(font_stats.most_common_size),
781 include_page_numbers: false,
782 strip_headers_footers: false,
783 ..markdown_options.clone()
784 };
785
786 let md = if has_text_quality_issue {
787 String::new()
788 } else {
789 markdown::to_markdown_from_items_with_rects_and_lines(
790 page_items,
791 options,
792 &page_rects,
793 &page_lines,
794 markdown::MarkdownDocumentContext {
795 page_thresholds: &page_thresholds,
796 struct_roles: None,
797 struct_tables: &[],
798 page_count,
799 prefiltered_page_number_pages: Some(&removed_page_number_pages),
800 prefiltered_page_number_mask: Some(&page_number_removal_mask),
801 precomputed_chart_regions: Some(&chart_regions),
802 },
803 )
804 };
805
806 let has_decoding_issue = has_text_quality_issue
807 || (!md.is_empty() && (is_cid_garbage(&md) || detect_encoding_issues(&md)));
808 if has_invisible_text_layer {
814 add_ocr_reason(
815 &mut ocr_reasons_by_page,
816 page_1idx,
817 OCR_REASON_INVISIBLE_TEXT_LAYER,
818 );
819 }
820 if has_decoding_issue {
821 add_ocr_reason(
822 &mut ocr_reasons_by_page,
823 page_1idx,
824 OCR_REASON_SUSPECTED_GARBLED_TEXT,
825 );
826 }
827 if has_template_image {
828 add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_SCANNED);
829 }
830 if has_vector_text {
831 add_ocr_reason(&mut ocr_reasons_by_page, page_1idx, OCR_REASON_VECTOR_TEXT);
832 }
833 let ocr_reason = page_ocr_reason(&ocr_reasons_by_page, page_1idx);
834
835 let needs_ocr = ocr_reason.is_some()
836 || md.trim().is_empty()
837 || has_gid
838 || is_garbage_text(&md)
839 || has_template_image
840 || has_vector_text
841 || has_invisible_text_layer;
842
843 if needs_ocr {
844 pages_needing_ocr.push(page_1idx);
845 }
846
847 results.push(PageMarkdown {
848 page: page_0idx,
849 markdown: if needs_ocr && !preserve_ocr_candidates {
854 String::new()
855 } else {
856 md
857 },
858 needs_ocr,
859 ocr_reason,
860 });
861 }
862
863 Ok(InternalPagesExtraction {
864 result: PagesExtractionResult {
865 pages: results,
866 pages_with_tables: complexity.pages_with_tables,
867 pages_with_columns: complexity.pages_with_columns,
868 pages_needing_ocr,
869 ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
870 is_complex: complexity.is_complex,
871 },
872 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
873 page_count,
874 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
875 supplemental_ocr_regions,
876 #[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
879 render_bytes: if render_repairs && repairs.repaired_forms() {
880 Some(
884 form_bbox_repair::serialize_for_rendering(&mut doc).ok_or_else(|| {
885 PdfError::Parse(
886 "the repaired document could not be written for rendering".to_string(),
887 )
888 })?,
889 )
890 } else {
891 None
892 },
893 })
894}
895
896#[cfg(any(test, all(feature = "ocr", not(target_arch = "wasm32"))))]
901fn supplemental_ocr_image_region(item: &TextItem) -> Option<PdfRect> {
902 const MIN_WIDTH_PT: f32 = 108.0;
903 const MIN_HEIGHT_PT: f32 = 72.0;
904 const MIN_AREA_PT2: f32 = 20_000.0;
905
906 if !matches!(item.item_type, types::ItemType::Image)
907 || !item.x.is_finite()
908 || !item.y.is_finite()
909 || !item.width.is_finite()
910 || !item.height.is_finite()
911 {
912 return None;
913 }
914 let x = item.x.min(item.x + item.width);
915 let y = item.y.min(item.y + item.height);
916 let width = item.width.abs();
917 let height = item.height.abs();
918 (width >= MIN_WIDTH_PT && height >= MIN_HEIGHT_PT && width * height >= MIN_AREA_PT2).then_some(
919 PdfRect {
920 x,
921 y,
922 width,
923 height,
924 page: item.page,
925 },
926 )
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Hash)]
930struct HeaderFooterItemKey {
931 page: u32,
932 x: u32,
933 y: u32,
934 text: String,
935}
936
937impl From<&TextItem> for HeaderFooterItemKey {
938 fn from(item: &TextItem) -> Self {
939 Self {
940 page: item.page,
941 x: item.x.to_bits(),
942 y: item.y.to_bits(),
943 text: item.text.clone(),
944 }
945 }
946}
947
948fn repeated_header_footer_item_keys(
949 items: &[TextItem],
950 page_thresholds: &HashMap<u32, f32>,
951 chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
952 page_count: u32,
953) -> HashSet<HeaderFooterItemKey> {
954 let candidates = items
955 .iter()
956 .filter(|item| {
957 matches!(
958 item.item_type,
959 types::ItemType::Text | types::ItemType::FormField
960 )
961 })
962 .cloned()
963 .collect();
964 let lines = extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
965 candidates,
966 page_thresholds,
967 &HashSet::new(),
968 chart_regions,
969 );
970 let all_items: HashSet<_> = lines
971 .iter()
972 .flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
973 .collect();
974 let kept = markdown::strip_repeated_header_footer_lines(lines, page_count);
975 let kept_items: HashSet<_> = kept
976 .iter()
977 .flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
978 .collect();
979 all_items.difference(&kept_items).cloned().collect()
980}
981
982#[cfg(all(test, feature = "ocr", not(target_arch = "wasm32")))]
983mod ocr_header_footer_tests {
984 use super::*;
985
986 fn item(page: u32, text: &str, y: f32) -> TextItem {
987 TextItem {
988 text: text.to_string(),
989 x: 10.0,
990 y,
991 width: 120.0,
992 height: 10.0,
993 font: "Test".to_string(),
994 font_tag: String::new(),
995 legacy_symbol_rewrite: false,
996 font_size: 10.0,
997 page,
998 is_bold: false,
999 is_italic: false,
1000 font_weight: None,
1001 bold_source: None,
1002 fixed_pitch: None,
1003 fill_color: None,
1004 stroke_color: None,
1005 render_mode: None,
1006 is_underline: false,
1007 is_strikeout: false,
1008 rotation: 0.0,
1009 advance_known: true,
1010 item_type: types::ItemType::Text,
1011 mcid: None,
1012 baseline_shift: 0.0,
1013 }
1014 }
1015
1016 #[test]
1017 fn local_pipeline_prefilters_document_wide_repeated_headers() {
1018 let mut items = Vec::new();
1019 let mut thresholds = HashMap::new();
1020 for page in 1..=3 {
1021 items.push(item(page, "Repeated report header", 800.0));
1022 for line in 0..12 {
1023 items.push(item(
1024 page,
1025 &format!("Page {page} paragraph {line} unique content"),
1026 700.0 - line as f32 * 40.0,
1027 ));
1028 }
1029 thresholds.insert(page, 0.1);
1030 }
1031
1032 let removed = repeated_header_footer_item_keys(&items, &thresholds, &HashMap::new(), 3);
1033 assert_eq!(removed.len(), 2);
1034 for page in 1..=3 {
1035 assert_eq!(
1036 removed.contains(&HeaderFooterItemKey::from(&item(
1037 page,
1038 "Repeated report header",
1039 800.0,
1040 ))),
1041 page > 1,
1042 );
1043 assert!(!removed.contains(&HeaderFooterItemKey::from(&item(
1044 page,
1045 &format!("Page {page} paragraph 5 unique content"),
1046 500.0,
1047 ))));
1048 }
1049 }
1050}
1051
1052pub fn extract_pages_markdown<P: AsRef<Path>>(
1058 path: P,
1059 pages: Option<&[u32]>,
1060) -> Result<PagesExtractionResult, PdfError> {
1061 validate_pdf_file(&path)?;
1062 let buffer = read_file(path.as_ref())?;
1063 extract_pages_markdown_mem(&buffer, pages)
1064}
1065
1066#[derive(Debug, Clone)]
1077pub struct StructureElement {
1078 pub page: u32,
1080 pub mcid: i64,
1083 pub role: String,
1087}
1088
1089pub fn extract_structure_elements_mem(
1099 buffer: &[u8],
1100 pages: Option<&[u32]>,
1101) -> Result<Vec<StructureElement>, PdfError> {
1102 validate_pdf_bytes(buffer)?;
1103 let (doc, _page_count) = load_document_from_mem(buffer)?;
1104 let Some(tree) = structure_tree::StructTree::from_doc(&doc) else {
1105 return Ok(Vec::new());
1106 };
1107 let page_ids = doc.get_pages();
1108 let roles = tree.mcid_to_roles(&page_ids);
1109
1110 let page_filter: Option<HashSet<u32>> = pages.map(|p| p.iter().copied().collect());
1111 let mut elements: Vec<StructureElement> = roles
1112 .into_iter()
1113 .filter(|(page, _)| page_filter.as_ref().is_none_or(|f| f.contains(page)))
1114 .flat_map(|(page, mcids)| {
1115 mcids.into_iter().map(move |(mcid, role)| StructureElement {
1116 page,
1117 mcid,
1118 role: role.name().to_string(),
1119 })
1120 })
1121 .collect();
1122 elements.sort_unstable_by_key(|e| (e.page, e.mcid));
1123 Ok(elements)
1124}
1125
1126pub fn extract_structure_elements<P: AsRef<Path>>(
1132 path: P,
1133 pages: Option<&[u32]>,
1134) -> Result<Vec<StructureElement>, PdfError> {
1135 validate_pdf_file(&path)?;
1136 let buffer = read_file(path.as_ref())?;
1137 extract_structure_elements_mem(&buffer, pages)
1138}
1139
1140#[derive(Debug)]
1146pub struct RegionText {
1147 pub text: String,
1149 pub needs_ocr: bool,
1153 pub ocr_reason: Option<String>,
1155}
1156
1157#[derive(Debug)]
1159pub struct PageRegionResult {
1160 pub page: u32,
1162 pub regions: Vec<RegionText>,
1164}
1165
1166const OCR_LAYER_MIN_ALNUM: usize = 40;
1170
1171fn non_placeholder_alnum(items: &[TextItem]) -> usize {
1176 items
1177 .iter()
1178 .filter(|it| !matches!(it.item_type, types::ItemType::Image))
1179 .map(|it| it.text.chars().filter(|c| c.is_alphanumeric()).count())
1180 .sum()
1181}
1182
1183pub fn extract_text_in_regions_mem(
1210 buffer: &[u8],
1211 page_regions: &[(u32, Vec<[f32; 4]>)],
1212) -> Result<Vec<PageRegionResult>, PdfError> {
1213 extract_text_in_regions_mem_in_frame(buffer, page_regions, PositionFrame::Sheet)
1214}
1215
1216pub fn extract_text_in_regions_mem_in_frame(
1224 buffer: &[u8],
1225 page_regions: &[(u32, Vec<[f32; 4]>)],
1226 frame: PositionFrame,
1227) -> Result<Vec<PageRegionResult>, PdfError> {
1228 extract_text_in_regions_mem_with_options(
1229 buffer,
1230 page_regions,
1231 PositionOptions::new().frame(frame),
1232 )
1233}
1234
1235pub fn extract_text_in_regions_mem_with_options(
1243 buffer: &[u8],
1244 page_regions: &[(u32, Vec<[f32; 4]>)],
1245 options: PositionOptions,
1246) -> Result<Vec<PageRegionResult>, PdfError> {
1247 let frame = options.frame;
1248 validate_pdf_bytes(buffer)?;
1249 let (doc, _page_count) = load_document_from_mem(buffer)?;
1250 let pages = doc.get_pages();
1251
1252 let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
1254
1255 let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1259
1260 let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
1262 let mut page_heights: HashMap<u32, f32> = HashMap::new();
1263 let mut gid_pages: HashSet<u32> = HashSet::new();
1264 let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
1265 let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
1266 let mut display_pages: HashMap<u32, extractor::DisplayPage> = HashMap::new();
1267 let mut style_cache = extractor::FontStyleCache::new();
1268
1269 for (page_num, &page_id) in pages.iter() {
1270 if !needed_pages.contains(page_num) {
1271 continue;
1272 }
1273
1274 let mut form_budget = extractor::FormWalkBudget::new();
1279 let extractor::PageBoxExtraction {
1280 mut items,
1281 has_gid_fonts: mut has_gid,
1282 mut coords_rotated,
1283 skipped_invisible,
1284 page_box,
1285 ..
1286 } = extractor::extract_page_text_items_in_page_box_with_options(
1287 &doc,
1288 page_id,
1289 *page_num,
1290 &font_cmaps,
1291 options.text_extraction(false),
1292 &mut style_cache,
1293 &mut form_budget,
1294 )?;
1295 page_heights.insert(*page_num, page_box.height());
1296 if frame == PositionFrame::Display {
1297 display_pages.insert(
1298 *page_num,
1299 extractor::DisplayPage::new(&doc, page_id, page_box),
1300 );
1301 }
1302 let has_visible_text = items.iter().any(|it| {
1318 !matches!(it.item_type, types::ItemType::Image) && !it.text.trim().is_empty()
1319 });
1320 if skipped_invisible && !has_visible_text {
1321 if let Ok(invisible) = extractor::extract_page_text_items_in_page_box_with_options(
1322 &doc,
1323 page_id,
1324 *page_num,
1325 &font_cmaps,
1326 options.text_extraction(true),
1327 &mut style_cache,
1328 &mut form_budget,
1329 ) {
1330 let inv_items = invisible.items;
1331 let inv_alnum = non_placeholder_alnum(&inv_items);
1332 let sample: String = inv_items
1336 .iter()
1337 .filter(|it| !matches!(it.item_type, types::ItemType::Image))
1338 .map(|it| it.text.as_str())
1339 .collect();
1340 if inv_alnum >= OCR_LAYER_MIN_ALNUM && !is_garbage_text(&sample) {
1341 items = inv_items;
1342 has_gid = invisible.has_gid_fonts;
1343 coords_rotated = invisible.coords_rotated;
1344 }
1345 }
1346 }
1347 let threshold = text_utils::fix_letterspaced_items(&mut items);
1348 if threshold > 0.10 {
1349 page_thresholds.insert(*page_num, threshold);
1350 }
1351 if has_gid {
1352 gid_pages.insert(*page_num);
1353 }
1354 if coords_rotated != extractor::geometry::PageRotation::Upright {
1355 rotated_pages.insert(*page_num, coords_rotated.into());
1356 }
1357 items_by_page.insert(*page_num, items);
1358 }
1359
1360 let mut results = Vec::with_capacity(page_regions.len());
1362
1363 for (page_0idx, regions) in page_regions {
1364 let page_1idx = page_0idx + 1;
1365 let items = items_by_page.get(&page_1idx);
1366 let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
1367 let _page_has_gid = gid_pages.contains(&page_1idx);
1368 let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
1369 let coords = rotated_pages
1370 .get(&page_1idx)
1371 .copied()
1372 .unwrap_or(RegionCoordSpace::Standard);
1373 let display = display_pages.get(&page_1idx).copied();
1374
1375 let mut page_results = Vec::with_capacity(regions.len());
1376
1377 let all_bounds: Vec<RegionBounds> = regions
1386 .iter()
1387 .map(|rect| {
1388 let [rx1, ry1, rx2, ry2] = sheet_region_rect(*rect, display);
1389 region_bounds(rx1, ry1, rx2, ry2, page_h, coords)
1390 })
1391 .collect();
1392 let mut region_items: Vec<Vec<TextItem>> = vec![Vec::new(); regions.len()];
1397 let mut had_candidates: Vec<bool> = vec![false; regions.len()];
1398 if let Some(items) = items {
1399 for item in items {
1400 let mut best: Option<usize> = None;
1401 let mut best_area = 0.0_f32;
1402 for (ri, b) in all_bounds.iter().enumerate() {
1403 if !region_overlaps_item(item, *b) {
1404 continue;
1405 }
1406 had_candidates[ri] = true;
1407 let area = region_item_overlap_area(item, *b);
1408 if area > best_area {
1409 best_area = area;
1410 best = Some(ri);
1411 }
1412 }
1413 if let Some(ri) = best {
1414 region_items[ri].push(item.clone());
1415 }
1416 }
1417 }
1418
1419 for (region_idx, _rect) in regions.iter().enumerate() {
1420 let matched: Vec<TextItem> = std::mem::take(&mut region_items[region_idx]);
1421 let assigned_count = matched.len();
1422 let has_text_quality_issue = region_items_have_decoding_issue(&matched);
1423 let text = collect_text_from_matched_items(matched, adaptive_threshold);
1424 let has_cid_issue = is_cid_garbage(&text);
1425 let has_encoding_issue = detect_encoding_issues(&text);
1426 let ocr_reason = if has_text_quality_issue || has_cid_issue || has_encoding_issue {
1427 Some(suspected_garbled_reason())
1428 } else {
1429 None
1430 };
1431
1432 let lost_to_neighbor = text.trim().is_empty()
1445 && ocr_reason.is_none()
1446 && assigned_count == 0
1447 && had_candidates[region_idx];
1448 let needs_ocr = !lost_to_neighbor
1449 && (ocr_reason.is_some() || text.trim().is_empty() || is_garbage_text(&text));
1450
1451 page_results.push(RegionText {
1452 text,
1453 needs_ocr,
1454 ocr_reason,
1455 });
1456 }
1457
1458 results.push(PageRegionResult {
1459 page: *page_0idx,
1460 regions: page_results,
1461 });
1462 }
1463
1464 Ok(results)
1465}
1466
1467pub fn extract_tables_in_regions_mem(
1478 buffer: &[u8],
1479 page_regions: &[(u32, Vec<[f32; 4]>)],
1480) -> Result<Vec<PageRegionResult>, PdfError> {
1481 extract_tables_in_regions_mem_in_frame(buffer, page_regions, PositionFrame::Sheet)
1482}
1483
1484pub fn extract_tables_in_regions_mem_in_frame(
1487 buffer: &[u8],
1488 page_regions: &[(u32, Vec<[f32; 4]>)],
1489 frame: PositionFrame,
1490) -> Result<Vec<PageRegionResult>, PdfError> {
1491 extract_tables_in_regions_mem_with_options(
1492 buffer,
1493 page_regions,
1494 PositionOptions::new().frame(frame),
1495 )
1496}
1497
1498pub fn extract_tables_in_regions_mem_with_options(
1501 buffer: &[u8],
1502 page_regions: &[(u32, Vec<[f32; 4]>)],
1503 options: PositionOptions,
1504) -> Result<Vec<PageRegionResult>, PdfError> {
1505 let frame = options.frame;
1506 validate_pdf_bytes(buffer)?;
1507 let (doc, _page_count) = load_document_from_mem(buffer)?;
1508 let pages = doc.get_pages();
1509
1510 let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
1511 let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1512
1513 let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
1514 let mut rects_by_page: HashMap<u32, Vec<PdfRect>> = HashMap::new();
1515 let mut lines_by_page: HashMap<u32, Vec<PdfLine>> = HashMap::new();
1516 let mut page_heights: HashMap<u32, f32> = HashMap::new();
1517 let mut gid_pages: HashSet<u32> = HashSet::new();
1518 let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
1519 let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
1520 let mut display_pages: HashMap<u32, extractor::DisplayPage> = HashMap::new();
1521 let mut style_cache = extractor::FontStyleCache::new();
1522
1523 for (page_num, &page_id) in pages.iter() {
1524 if !needed_pages.contains(page_num) {
1525 continue;
1526 }
1527 let extractor::PageBoxExtraction {
1528 mut items,
1529 rects,
1530 lines,
1531 has_gid_fonts: has_gid,
1532 coords_rotated,
1533 page_box,
1534 ..
1535 } = extractor::extract_page_text_items_in_page_box_with_options(
1536 &doc,
1537 page_id,
1538 *page_num,
1539 &font_cmaps,
1540 options.text_extraction(false),
1541 &mut style_cache,
1542 &mut extractor::FormWalkBudget::new(),
1543 )?;
1544 page_heights.insert(*page_num, page_box.height());
1545 if frame == PositionFrame::Display {
1546 display_pages.insert(
1547 *page_num,
1548 extractor::DisplayPage::new(&doc, page_id, page_box),
1549 );
1550 }
1551 let threshold = text_utils::fix_letterspaced_items(&mut items);
1552 if threshold > 0.10 {
1553 page_thresholds.insert(*page_num, threshold);
1554 }
1555 if has_gid {
1556 gid_pages.insert(*page_num);
1557 }
1558 if coords_rotated != extractor::geometry::PageRotation::Upright {
1559 rotated_pages.insert(*page_num, coords_rotated.into());
1560 }
1561 items_by_page.insert(*page_num, items);
1562 rects_by_page.insert(*page_num, rects);
1563 lines_by_page.insert(*page_num, lines);
1564 }
1565
1566 let mut results = Vec::with_capacity(page_regions.len());
1567
1568 for (page_0idx, regions) in page_regions {
1569 let page_1idx = page_0idx + 1;
1570 let items = items_by_page.get(&page_1idx);
1571 let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
1572 let _page_has_gid = gid_pages.contains(&page_1idx);
1573 let coords = rotated_pages
1574 .get(&page_1idx)
1575 .copied()
1576 .unwrap_or(RegionCoordSpace::Standard);
1577 let display = display_pages.get(&page_1idx).copied();
1578
1579 let mut page_results = Vec::with_capacity(regions.len());
1580
1581 for rect in regions {
1582 let [rx1, ry1, rx2, ry2] = sheet_region_rect(*rect, display);
1583
1584 let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
1594 let matched: Vec<TextItem> = match items {
1595 Some(items) => items
1596 .iter()
1597 .filter(|item| region_overlaps_item(item, bounds))
1598 .cloned()
1599 .collect(),
1600 None => Vec::new(),
1601 };
1602
1603 if matched.is_empty() {
1604 page_results.push(RegionText {
1605 text: String::new(),
1606 needs_ocr: true,
1607 ocr_reason: None,
1608 });
1609 continue;
1610 }
1611
1612 if region_items_have_decoding_issue(&matched) {
1613 page_results.push(RegionText {
1614 text: String::new(),
1615 needs_ocr: true,
1616 ocr_reason: Some(suspected_garbled_reason()),
1617 });
1618 continue;
1619 }
1620
1621 let base_font_size = {
1623 let mut freq: HashMap<i32, usize> = HashMap::new();
1624 for item in &matched {
1625 *freq.entry((item.font_size * 10.0) as i32).or_default() += 1;
1626 }
1627 freq.into_iter()
1628 .max_by_key(|(_, count)| *count)
1629 .map(|(size, _)| size as f32 / 10.0)
1630 .unwrap_or(12.0)
1631 };
1632
1633 let region_rects: Vec<PdfRect> = rects_by_page
1649 .get(&page_1idx)
1650 .map(|rs| {
1651 rs.iter()
1652 .filter(|r| region_overlaps_rect(r, bounds))
1653 .cloned()
1654 .collect()
1655 })
1656 .unwrap_or_default();
1657 let region_lines: Vec<PdfLine> = lines_by_page
1658 .get(&page_1idx)
1659 .map(|ls| {
1660 ls.iter()
1661 .filter(|l| region_overlaps_line(l, bounds))
1662 .cloned()
1663 .collect()
1664 })
1665 .unwrap_or_default();
1666
1667 let region_text_chars: usize = matched.iter().map(|i| i.text.chars().count()).sum();
1670 let region_area = (rx2 - rx1).max(0.0) * (ry2 - ry1).max(0.0);
1671 let line_region_has_vertical_rules = has_vertical_rules(®ion_lines);
1672
1673 let evaluate =
1674 |source: TableCandidateSource, t: &tables::Table| -> Option<TableCandidate> {
1675 let md = tables::table_to_markdown(t);
1676 let trimmed = md.trim();
1677 if trimmed.is_empty() {
1678 return None;
1679 }
1680 if is_garbage_text(&md)
1681 || is_cid_garbage(&md)
1682 || detect_encoding_issues(&md)
1683 || looks_like_partial_table_ex(&md, true)
1684 {
1685 return None;
1686 }
1687 if captured_only_a_fragment(&md, region_text_chars) {
1700 return None;
1701 }
1702 if source != TableCandidateSource::KeyValue
1716 && region_text_density_too_low(region_text_chars, region_area)
1717 && !markdown_table_body_is_dense(&md)
1718 {
1719 return None;
1720 }
1721 let shape = markdown_table_shape(&md);
1722 let issue = if source == TableCandidateSource::Line
1723 && line_region_has_vertical_rules
1724 && line_table_collapses_text_rows(t, &matched, shape)
1725 {
1726 Some(TableCandidateIssue::LineRowUndercount)
1727 } else if wide_table_sparse_prefix_undercount(&md) {
1728 Some(TableCandidateIssue::SparseWideUndercount)
1729 } else if !matches!(
1730 source,
1731 TableCandidateSource::Line | TableCandidateSource::KeyValue
1732 ) && text_cluster_column_undercount(&matched, shape)
1733 {
1734 Some(TableCandidateIssue::TextColumnUndercount)
1735 } else if prose_grid_fragment_needs_ocr(&md) {
1736 Some(TableCandidateIssue::ProseGridFragment)
1737 } else {
1738 None
1739 };
1740 Some(TableCandidate {
1741 markdown: md,
1742 source,
1743 shape,
1744 issue,
1745 })
1746 };
1747
1748 let mut candidates: Vec<TableCandidate> = Vec::new();
1749 if !region_rects.is_empty() {
1750 let (rect_tables, _) =
1751 tables::detect_tables_from_rects(&matched, ®ion_rects, page_1idx);
1752 if let Some(candidate) = rect_tables
1753 .iter()
1754 .find_map(|t| evaluate(TableCandidateSource::Rect, t))
1755 {
1756 candidates.push(candidate);
1757 }
1758 }
1759 if !region_lines.is_empty() {
1760 let line_tables =
1761 tables::detect_tables_from_lines(&matched, ®ion_lines, page_1idx);
1762 if let Some(candidate) = line_tables
1763 .iter()
1764 .find_map(|t| evaluate(TableCandidateSource::Line, t))
1765 {
1766 candidates.push(candidate);
1767 }
1768 }
1769 let detected = tables::detect_tables_with_page_width(
1770 &matched,
1771 base_font_size,
1772 false,
1773 items.map_or(1.0, |items| tables::content_width(items)),
1774 );
1775 if let Some(candidate) = detected
1776 .iter()
1777 .find_map(|t| evaluate(TableCandidateSource::Heuristic, t))
1778 {
1779 candidates.push(candidate);
1780 }
1781 if let Some(table) = tables::try_build_table_from_columns(&matched, page_1idx) {
1782 if let Some(candidate) = evaluate(TableCandidateSource::Column, &table) {
1783 candidates.push(candidate);
1784 }
1785 }
1786 if let Some(table) = tables::try_build_key_value_table_from_rows(&matched, page_1idx) {
1787 if let Some(candidate) = evaluate(TableCandidateSource::KeyValue, &table) {
1788 candidates.push(candidate);
1789 }
1790 }
1791
1792 match select_table_candidate(&candidates) {
1793 Some(candidate) => page_results.push(RegionText {
1794 text: candidate.markdown.clone(),
1795 needs_ocr: false,
1796 ocr_reason: None,
1797 }),
1798 None => page_results.push(RegionText {
1799 text: String::new(),
1800 needs_ocr: true,
1801 ocr_reason: None,
1802 }),
1803 }
1804 }
1805
1806 results.push(PageRegionResult {
1807 page: *page_0idx,
1808 regions: page_results,
1809 });
1810 }
1811
1812 Ok(results)
1813}
1814
1815#[derive(Debug, Clone)]
1817pub struct VectorGridDetection {
1818 pub structure_tokens: Vec<String>,
1820 pub cell_bboxes: Vec<Vec<f32>>,
1822}
1823
1824#[derive(Clone, Copy)]
1825enum VectorGridSource {
1826 Rects,
1827 Lines,
1828}
1829
1830pub fn detect_vector_grid_in_region_mem(
1840 buffer: &[u8],
1841 page_idx: u32,
1842 region_pdf_pt_bbox: [f32; 4],
1843 render_dpi: f32,
1844) -> Result<Option<VectorGridDetection>, PdfError> {
1845 validate_pdf_bytes(buffer)?;
1846 let (doc, _page_count) = load_document_from_mem(buffer)?;
1847 let pages = doc.get_pages();
1848
1849 let page_1idx = page_idx + 1;
1850 let Some(&page_id) = pages.get(&page_1idx) else {
1851 return Ok(None);
1852 };
1853
1854 let needed_pages = HashSet::from([page_1idx]);
1855 let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
1856 let extractor::PageBoxExtraction {
1857 mut items,
1858 rects,
1859 lines,
1860 coords_rotated,
1861 page_box,
1862 ..
1863 } = extractor::extract_page_text_items_in_page_box(
1864 &doc,
1865 page_id,
1866 page_1idx,
1867 &font_cmaps,
1868 false,
1869 &mut extractor::FontStyleCache::new(),
1870 &mut extractor::FormWalkBudget::new(),
1871 )?;
1872 let page_h = page_box.height();
1873 text_utils::fix_letterspaced_items(&mut items);
1874
1875 let coords = RegionCoordSpace::from(coords_rotated);
1876 if coords != RegionCoordSpace::Standard {
1877 return Ok(None);
1882 }
1883 let [rx1, ry1, rx2, ry2] = region_pdf_pt_bbox;
1884 let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
1885
1886 let items_in_region: Vec<TextItem> = items
1887 .iter()
1888 .filter(|item| region_overlaps_item(item, bounds))
1889 .cloned()
1890 .collect();
1891 if items_in_region.is_empty() {
1892 return Ok(None);
1893 }
1894
1895 let rects_in_region: Vec<PdfRect> = rects
1896 .iter()
1897 .filter(|rect| region_overlaps_rect(rect, bounds))
1898 .cloned()
1899 .collect();
1900 let lines_in_region: Vec<PdfLine> = lines
1901 .iter()
1902 .filter(|line| region_overlaps_line(line, bounds))
1903 .cloned()
1904 .collect();
1905
1906 let (rect_tables, _) =
1911 tables::detect_tables_from_rects(&items_in_region, &rects_in_region, page_1idx);
1912 for table in rect_tables {
1913 if let Some(result) = vector_grid_result_from_table(
1914 &table,
1915 VectorGridSource::Rects,
1916 &rects_in_region,
1917 &lines_in_region,
1918 region_pdf_pt_bbox,
1919 render_dpi,
1920 page_h,
1921 coords,
1922 ) {
1923 return Ok(Some(result));
1924 }
1925 }
1926
1927 let line_tables =
1928 tables::detect_vector_grid_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
1929 for table in line_tables {
1930 if let Some(result) = vector_grid_result_from_table(
1931 &table,
1932 VectorGridSource::Lines,
1933 &rects_in_region,
1934 &lines_in_region,
1935 region_pdf_pt_bbox,
1936 render_dpi,
1937 page_h,
1938 coords,
1939 ) {
1940 return Ok(Some(result));
1941 }
1942 }
1943
1944 Ok(None)
1945}
1946
1947#[allow(clippy::too_many_arguments)]
1948fn vector_grid_result_from_table(
1949 table: &tables::Table,
1950 source: VectorGridSource,
1951 rects: &[PdfRect],
1952 lines: &[PdfLine],
1953 crop_pdf_pt_bbox: [f32; 4],
1954 render_dpi: f32,
1955 page_height: f32,
1956 coord_space: RegionCoordSpace,
1957) -> Option<VectorGridDetection> {
1958 let num_rows = table.cells.len();
1959 let num_cols = table.cells.first().map_or(0, Vec::len);
1960 if num_rows == 0 || num_cols == 0 || table.cells.iter().any(|row| row.len() != num_cols) {
1961 return None;
1962 }
1963
1964 let (x_edges, y_edges) = match source {
1965 VectorGridSource::Rects => rect_grid_edges(rects, num_cols, num_rows)
1966 .or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
1967 VectorGridSource::Lines => line_grid_edges(table, lines, num_cols, num_rows)
1968 .or_else(|| inferred_grid_edges(table, rects, lines, num_cols, num_rows))?,
1969 };
1970
1971 if x_edges.len() != num_cols + 1 || y_edges.len() != num_rows + 1 {
1972 return None;
1973 }
1974
1975 let mut structure_tokens = Vec::with_capacity(num_rows * (num_cols + 2) + 2);
1976 let mut cell_bboxes = Vec::with_capacity(num_rows * num_cols);
1977 structure_tokens.push("<table>".to_string());
1978
1979 for r in 0..num_rows {
1986 structure_tokens.push("<tr>".to_string());
1987 for c in 0..num_cols {
1988 structure_tokens.push("<td></td>".to_string());
1989 let bbox_px = extracted_cell_to_crop_px(
1990 [x_edges[c], y_edges[r + 1], x_edges[c + 1], y_edges[r]],
1991 crop_pdf_pt_bbox,
1992 render_dpi,
1993 page_height,
1994 coord_space,
1995 )?;
1996 if !crop_px_bbox_is_plausible(bbox_px, crop_pdf_pt_bbox, render_dpi) {
1997 return None;
1998 }
1999 cell_bboxes.push(bbox_px.to_vec());
2000 }
2001 structure_tokens.push("</tr>".to_string());
2002 }
2003
2004 structure_tokens.push("</table>".to_string());
2005 Some(VectorGridDetection {
2006 structure_tokens,
2007 cell_bboxes,
2008 })
2009}
2010
2011fn crop_px_bbox_is_plausible(
2012 bbox_px: [f32; 4],
2013 crop_pdf_pt_bbox: [f32; 4],
2014 render_dpi: f32,
2015) -> bool {
2016 let ppi = if render_dpi > 0.0 {
2017 render_dpi / 72.0
2018 } else {
2019 1.0
2020 };
2021 let crop_w = (crop_pdf_pt_bbox[2] - crop_pdf_pt_bbox[0]).abs() * ppi;
2022 let crop_h = (crop_pdf_pt_bbox[3] - crop_pdf_pt_bbox[1]).abs() * ppi;
2023 let slack = 1.0;
2024 bbox_px[0] >= -slack
2025 && bbox_px[1] >= -slack
2026 && bbox_px[2] <= crop_w + slack
2027 && bbox_px[3] <= crop_h + slack
2028}
2029
2030#[cfg(test)]
2031mod vector_grid_tests {
2032 use super::crop_px_bbox_is_plausible;
2033
2034 #[test]
2039 fn forecast_table_chart_six_cols() {
2040 use crate::extractor::content_stream::extract_page_text_items;
2041 use crate::tables::detect_tables_from_rects;
2042 use crate::tounicode::FontCMaps;
2043 use lopdf::Document;
2044 use std::collections::HashSet;
2045 use std::fs;
2046
2047 let path = "tests/fixtures/forecast_table_chart.pdf";
2048 let buf = fs::read(path).unwrap();
2049 let doc = Document::load_mem(&buf).unwrap();
2050 let pages = doc.get_pages();
2051 let &page_id = pages.get(&1).unwrap();
2052 let needed: HashSet<u32> = HashSet::from([1]);
2053 let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
2054 let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
2055 extract_page_text_items(
2056 &doc,
2057 page_id,
2058 1,
2059 &cmaps,
2060 false,
2061 &mut crate::extractor::FontStyleCache::new(),
2062 &mut crate::extractor::FormWalkBudget::new(),
2063 )
2064 .unwrap();
2065
2066 let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
2067 assert_eq!(rect_tables.len(), 1, "expected one rect-detected table");
2068 let t = &rect_tables[0];
2069 assert_eq!(
2070 t.columns.len(),
2071 6,
2072 "doc 128 has a 6-column table; got {} edges: {:?}",
2073 t.columns.len(),
2074 t.columns
2075 );
2076 assert!(
2077 t.rows.len() >= 14 && t.rows.len() <= 17,
2078 "row count drift: {}",
2079 t.rows.len()
2080 );
2081 }
2082
2083 fn detect_rect_tables_in_fixture_page(path: &str, page_num: u32) -> Vec<crate::tables::Table> {
2085 use crate::extractor::content_stream::extract_page_text_items;
2086 use crate::tables::detect_tables_from_rects;
2087 use crate::tounicode::FontCMaps;
2088 use lopdf::Document;
2089 use std::collections::HashSet;
2090 use std::fs;
2091
2092 let buf = fs::read(path).unwrap();
2093 let doc = Document::load_mem(&buf).unwrap();
2094 let pages = doc.get_pages();
2095 let &page_id = pages.get(&page_num).unwrap();
2096 let needed: HashSet<u32> = HashSet::from([page_num]);
2097 let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
2098 let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
2099 extract_page_text_items(
2100 &doc,
2101 page_id,
2102 page_num,
2103 &cmaps,
2104 false,
2105 &mut crate::extractor::FontStyleCache::new(),
2106 &mut crate::extractor::FormWalkBudget::new(),
2107 )
2108 .unwrap();
2109
2110 let (rect_tables, _) = detect_tables_from_rects(&items, &rects, page_num);
2111 rect_tables
2112 }
2113
2114 fn detect_rect_tables_in_fixture(path: &str) -> Vec<crate::tables::Table> {
2115 detect_rect_tables_in_fixture_page(path, 1)
2116 }
2117
2118 #[test]
2129 fn accessory_building_rejects_prose_in_frame() {
2130 let tables = detect_rect_tables_in_fixture(
2131 "tests/fixtures/accessory_building_permit_prose_frame.pdf",
2132 );
2133 let data_table = tables.iter().find(|t| t.columns.len() == 3);
2135 assert!(
2136 data_table.is_some(),
2137 "expected to keep the 5×3 TYPE/SIZE/SETBACKS data table; got {:?}",
2138 tables
2139 .iter()
2140 .map(|t| (t.rows.len(), t.columns.len()))
2141 .collect::<Vec<_>>()
2142 );
2143 let prose_table = tables.iter().find(|t| t.columns.len() == 2);
2148 assert!(
2149 prose_table.is_none(),
2150 "expected the 10×2 prose-in-frame block to be rejected; got rows×cols = {:?}",
2151 prose_table.map(|t| (t.rows.len(), t.columns.len()))
2152 );
2153 }
2154
2155 #[test]
2156 fn td9264_insurance_prose_not_rect_table() {
2157 let tables = detect_rect_tables_in_fixture_page("tests/fixtures/td9264.pdf", 4);
2158 assert!(
2159 tables.is_empty(),
2160 "expected no rect-detected tables for the insurance-company prose; got {:?}",
2161 tables
2162 .iter()
2163 .map(|t| (t.rows.len(), t.columns.len(), t.cells.clone()))
2164 .collect::<Vec<_>>()
2165 );
2166 }
2167
2168 #[test]
2172 fn wireless_two_col_rejects_rect_grid() {
2173 let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_two_col_no_rects.pdf");
2174 assert!(
2175 tables.is_empty(),
2176 "expected no rect-detected tables for wireless content; got {:?}",
2177 tables
2178 .iter()
2179 .map(|t| (t.rows.len(), t.columns.len()))
2180 .collect::<Vec<_>>()
2181 );
2182 }
2183
2184 #[test]
2185 fn wireless_two_col_region_rejects_vector_grid() {
2186 let buf = std::fs::read("tests/fixtures/wireless_two_col_no_rects.pdf").unwrap();
2187 let crops = [
2188 [49.32_f32, 52.92, 558.72, 214.2],
2189 [49.32_f32, 288.72, 556.56, 378.0],
2190 [51.48_f32, 478.44, 558.36, 567.36],
2191 ];
2192 for crop in crops {
2193 let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
2194 assert!(
2195 detected.is_none(),
2196 "expected no vector grid for wireless crop {crop:?}; got {} cells",
2197 detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
2198 );
2199 }
2200 }
2201
2202 #[test]
2205 fn wireless_dense_rejects_rect_grid() {
2206 let tables = detect_rect_tables_in_fixture("tests/fixtures/wireless_dense_no_rects.pdf");
2207 assert!(
2208 tables.is_empty(),
2209 "expected no rect-detected tables for wireless content; got {:?}",
2210 tables
2211 .iter()
2212 .map(|t| (t.rows.len(), t.columns.len()))
2213 .collect::<Vec<_>>()
2214 );
2215 }
2216
2217 #[test]
2218 fn wireless_dense_region_rejects_vector_grid() {
2219 let buf = std::fs::read("tests/fixtures/wireless_dense_no_rects.pdf").unwrap();
2220 let crops = [
2221 [72.36_f32, 177.48, 243.72, 333.36],
2222 [72.0_f32, 390.24, 286.92, 417.6],
2223 ];
2224 for crop in crops {
2225 let detected = crate::detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0).unwrap();
2226 assert!(
2227 detected.is_none(),
2228 "expected no vector grid for wireless crop {crop:?}; got {} cells",
2229 detected.map(|grid| grid.cell_bboxes.len()).unwrap_or(0)
2230 );
2231 }
2232 }
2233
2234 #[test]
2235 fn multiline_indent_cell_rect_grid_fixture_detects_table() {
2236 let tables = detect_rect_tables_in_fixture_page(
2237 "tests/fixtures/multiline_indent_cell_rect_grid.pdf",
2238 30,
2239 );
2240 let table = tables
2241 .iter()
2242 .max_by_key(|t| t.rows.len() * t.columns.len())
2243 .expect("expected a rect-detected table");
2244 assert_eq!(
2245 table.columns.len(),
2246 5,
2247 "expected the Controls Version / Control / IG table shape; got {:?}",
2248 tables
2249 .iter()
2250 .map(|t| (t.rows.len(), t.columns.len()))
2251 .collect::<Vec<_>>()
2252 );
2253 assert!(
2254 table.rows.len() >= 3,
2255 "expected at least header plus data rows; got {}",
2256 table.rows.len()
2257 );
2258 }
2259
2260 #[test]
2261 fn multiline_indent_cell_rect_grid_region_detects_vector_grid() {
2262 let buf = std::fs::read("tests/fixtures/multiline_indent_cell_rect_grid.pdf").unwrap();
2263 let detected =
2264 crate::detect_vector_grid_in_region_mem(&buf, 29, [0.0, 0.0, 612.0, 792.0], 200.0)
2265 .unwrap()
2266 .expect("expected vector grid for multiline indented description table");
2267 let rows = detected
2268 .structure_tokens
2269 .iter()
2270 .filter(|token| token.as_str() == "<tr>")
2271 .count();
2272 assert_eq!(detected.cell_bboxes.len() % rows, 0);
2273 assert_eq!(detected.cell_bboxes.len() / rows, 5);
2274 assert!(rows >= 3);
2275 assert!(!detected.cell_bboxes.is_empty());
2276 }
2277
2278 #[test]
2289 fn greencomp_competence_two_cols() {
2290 let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
2291 assert!(
2292 !tables.is_empty(),
2293 "expected at least one rect-detected table for shaded-header + plain-body shape"
2294 );
2295 let t = tables
2296 .iter()
2297 .max_by_key(|t| t.rows.len() * t.columns.len())
2298 .unwrap();
2299 assert_eq!(
2300 t.columns.len(),
2301 2,
2302 "GreenComp competence is a 2-column table; got {}: {:?}",
2303 t.columns.len(),
2304 t.columns
2305 );
2306 assert!(
2307 t.rows.len() >= 6,
2308 "expected at least 6 rows of competences; got {}",
2309 t.rows.len()
2310 );
2311 }
2312
2313 #[test]
2320 fn upstage_key_functions_four_cols() {
2321 let tables = detect_rect_tables_in_fixture("tests/fixtures/upstage_key_functions.pdf");
2322 assert!(
2323 !tables.is_empty(),
2324 "expected at least one rect-detected table for shaded-header + alt-row shape"
2325 );
2326 let t = tables
2327 .iter()
2328 .max_by_key(|t| t.rows.len() * t.columns.len())
2329 .unwrap();
2330 assert_eq!(
2331 t.columns.len(),
2332 4,
2333 "Service Flow is a 4-column table; got {}: {:?}",
2334 t.columns.len(),
2335 t.columns
2336 );
2337 assert!(
2338 t.rows.len() >= 8,
2339 "expected at least 8 visible body rows; got {}",
2340 t.rows.len()
2341 );
2342 }
2343
2344 #[test]
2354 fn wired_header_data_misalign_keeps_all_columns() {
2355 let tables = detect_rect_tables_in_fixture("tests/fixtures/wired_header_data_misalign.pdf");
2356 let table = tables
2357 .iter()
2358 .find(|t| t.columns.len() == 4 && t.rows.len() >= 5)
2359 .unwrap_or_else(|| {
2360 panic!(
2361 "expected a 4-column ≥5-row table; got {:?}",
2362 tables
2363 .iter()
2364 .map(|t| (t.rows.len(), t.columns.len()))
2365 .collect::<Vec<_>>()
2366 )
2367 });
2368 for c in 0..4 {
2369 let populated_rows = table
2370 .cells
2371 .iter()
2372 .filter(|row| !row[c].trim().is_empty())
2373 .count();
2374 assert!(
2375 populated_rows >= 2,
2376 "column {} only populated in {} rows; cells: {:?}",
2377 c,
2378 populated_rows,
2379 table.cells
2380 );
2381 }
2382 }
2383
2384 #[test]
2385 fn test_crop_px_bbox_is_plausible_bounds() {
2386 let crop = [10.0, 20.0, 110.0, 220.0];
2387
2388 assert!(crop_px_bbox_is_plausible(
2389 [0.0, 0.0, 100.0, 200.0],
2390 crop,
2391 72.0
2392 ));
2393 assert!(crop_px_bbox_is_plausible(
2394 [0.0, 0.0, 200.0, 400.0],
2395 crop,
2396 144.0
2397 ));
2398 assert!(!crop_px_bbox_is_plausible(
2399 [-2.0, 0.0, 50.0, 100.0],
2400 crop,
2401 72.0
2402 ));
2403 assert!(!crop_px_bbox_is_plausible(
2404 [0.0, -2.0, 50.0, 100.0],
2405 crop,
2406 72.0
2407 ));
2408 assert!(!crop_px_bbox_is_plausible(
2409 [0.0, 0.0, 102.0, 200.0],
2410 crop,
2411 72.0
2412 ));
2413 assert!(!crop_px_bbox_is_plausible(
2414 [0.0, 0.0, 100.0, 202.0],
2415 crop,
2416 72.0
2417 ));
2418
2419 assert!(crop_px_bbox_is_plausible(
2421 [-1.0, -1.0, 101.0, 201.0],
2422 crop,
2423 72.0
2424 ));
2425
2426 assert!(crop_px_bbox_is_plausible(
2428 [0.0, 0.0, 100.0, 200.0],
2429 crop,
2430 0.0
2431 ));
2432 assert!(crop_px_bbox_is_plausible(
2433 [0.0, 0.0, 100.0, 200.0],
2434 crop,
2435 -144.0
2436 ));
2437 assert!(!crop_px_bbox_is_plausible(
2438 [0.0, 0.0, 102.0, 200.0],
2439 crop,
2440 -144.0
2441 ));
2442 }
2443}
2444
2445fn line_grid_edges(
2446 table: &tables::Table,
2447 lines: &[PdfLine],
2448 num_cols: usize,
2449 num_rows: usize,
2450) -> Option<(Vec<f32>, Vec<f32>)> {
2451 if lines.is_empty() || table.columns.len() != num_cols + 1 || table.rows.len() != num_rows {
2452 return None;
2453 }
2454
2455 let angle_tolerance = 2.0_f32.to_radians().tan();
2456 let mut ys = Vec::new();
2457
2458 for line in lines {
2459 let dx = (line.x2 - line.x1).abs();
2460 let dy = (line.y2 - line.y1).abs();
2461 let length = (dx * dx + dy * dy).sqrt();
2462 if length < 20.0 {
2463 continue;
2464 }
2465 if dx > 0.01 && dy / dx <= angle_tolerance {
2466 ys.push((line.y1 + line.y2) * 0.5);
2467 }
2468 }
2469
2470 let mut x_edges = table.columns.clone();
2471 x_edges.sort_by(|a, b| a.total_cmp(b));
2472
2473 let snapped_y = snap_vector_edges(ys, true);
2474 let mut y_edges = Vec::with_capacity(num_rows + 1);
2475 for &row_top in &table.rows {
2476 let matched = snapped_y
2477 .iter()
2478 .copied()
2479 .find(|y| (*y - row_top).abs() <= 3.0)
2480 .unwrap_or(row_top);
2481 y_edges.push(matched);
2482 }
2483
2484 let last_top = *y_edges.last()?;
2485 let bottom = snapped_y
2486 .iter()
2487 .copied()
2488 .filter(|y| *y < last_top - 3.0)
2489 .max_by(|a, b| a.total_cmp(b))?;
2490 y_edges.push(bottom);
2491
2492 if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
2493 Some((x_edges, y_edges))
2494 } else {
2495 None
2496 }
2497}
2498
2499fn rect_grid_edges(
2500 rects: &[PdfRect],
2501 num_cols: usize,
2502 num_rows: usize,
2503) -> Option<(Vec<f32>, Vec<f32>)> {
2504 if rects.is_empty() {
2505 return None;
2506 }
2507
2508 let mut xs = Vec::new();
2509 let mut ys = Vec::new();
2510 for rect in rects {
2511 let (x1, y1, x2, y2) = normalized_rect_edges(rect);
2512 if (x2 - x1) < 5.0 || (y2 - y1) < 5.0 {
2513 continue;
2514 }
2515 xs.push(x1);
2516 xs.push(x2);
2517 ys.push(y1);
2518 ys.push(y2);
2519 }
2520
2521 let x_edges = snap_vector_edges(xs, false);
2522 let y_edges = snap_vector_edges(ys, true);
2523 if x_edges.len() == num_cols + 1 && y_edges.len() == num_rows + 1 {
2524 Some((x_edges, y_edges))
2525 } else {
2526 None
2527 }
2528}
2529
2530fn inferred_grid_edges(
2531 table: &tables::Table,
2532 rects: &[PdfRect],
2533 lines: &[PdfLine],
2534 num_cols: usize,
2535 num_rows: usize,
2536) -> Option<(Vec<f32>, Vec<f32>)> {
2537 let bounds = vector_geometry_bounds(rects, lines);
2538 let x_edges = if table.columns.len() == num_cols + 1 {
2539 let mut edges = table.columns.clone();
2540 edges.sort_by(|a, b| a.total_cmp(b));
2541 Some(edges)
2542 } else {
2543 infer_ascending_edges(&table.columns, num_cols, bounds.map(|b| (b.x_min, b.x_max)))
2544 }?;
2545
2546 let y_edges = if table.rows.len() == num_rows + 1 {
2547 let mut edges = table.rows.clone();
2548 edges.sort_by(|a, b| b.total_cmp(a));
2549 Some(edges)
2550 } else {
2551 infer_descending_edges(&table.rows, num_rows, bounds.map(|b| (b.y_min, b.y_max)))
2552 }?;
2553
2554 Some((x_edges, y_edges))
2555}
2556
2557fn infer_ascending_edges(
2558 positions: &[f32],
2559 expected_centers: usize,
2560 bounds: Option<(f32, f32)>,
2561) -> Option<Vec<f32>> {
2562 if positions.len() != expected_centers || positions.is_empty() {
2563 return None;
2564 }
2565 let mut centers = positions.to_vec();
2566 centers.sort_by(|a, b| a.total_cmp(b));
2567 if centers.len() == 1 {
2568 return None;
2569 }
2570
2571 let mut edges = Vec::with_capacity(centers.len() + 1);
2572 let first_gap = centers[1] - centers[0];
2573 let last_gap = centers[centers.len() - 1] - centers[centers.len() - 2];
2574 let left = bounds
2575 .map(|(min, _)| min)
2576 .filter(|min| min.is_finite() && *min < centers[0])
2577 .unwrap_or(centers[0] - first_gap * 0.5);
2578 let right = bounds
2579 .map(|(_, max)| max)
2580 .filter(|max| max.is_finite() && *max > *centers.last().unwrap())
2581 .unwrap_or(*centers.last().unwrap() + last_gap * 0.5);
2582
2583 edges.push(left);
2584 for pair in centers.windows(2) {
2585 edges.push((pair[0] + pair[1]) * 0.5);
2586 }
2587 edges.push(right);
2588
2589 strictly_ordered(&edges, false).then_some(edges)
2590}
2591
2592fn infer_descending_edges(
2593 positions: &[f32],
2594 expected_centers: usize,
2595 bounds: Option<(f32, f32)>,
2596) -> Option<Vec<f32>> {
2597 if positions.len() != expected_centers || positions.is_empty() {
2598 return None;
2599 }
2600 let mut centers = positions.to_vec();
2601 centers.sort_by(|a, b| b.total_cmp(a));
2602 if centers.len() == 1 {
2603 return None;
2604 }
2605
2606 let mut edges = Vec::with_capacity(centers.len() + 1);
2607 let first_gap = centers[0] - centers[1];
2608 let last_gap = centers[centers.len() - 2] - centers[centers.len() - 1];
2609 let top = bounds
2610 .map(|(_, max)| max)
2611 .filter(|max| max.is_finite() && *max > centers[0])
2612 .unwrap_or(centers[0] + first_gap * 0.5);
2613 let bottom = bounds
2614 .map(|(min, _)| min)
2615 .filter(|min| min.is_finite() && *min < *centers.last().unwrap())
2616 .unwrap_or(*centers.last().unwrap() - last_gap * 0.5);
2617
2618 edges.push(top);
2619 for pair in centers.windows(2) {
2620 edges.push((pair[0] + pair[1]) * 0.5);
2621 }
2622 edges.push(bottom);
2623
2624 strictly_ordered(&edges, true).then_some(edges)
2625}
2626
2627fn snap_vector_edges(mut values: Vec<f32>, descending: bool) -> Vec<f32> {
2628 values.retain(|v| v.is_finite());
2629 values.sort_by(|a, b| a.total_cmp(b));
2630
2631 let mut snapped: Vec<f32> = Vec::new();
2632 let mut cluster: Vec<f32> = Vec::new();
2633 for value in values {
2634 if cluster
2635 .last()
2636 .is_some_and(|last| (value - *last).abs() <= 3.0)
2637 {
2638 cluster.push(value);
2639 } else {
2640 if !cluster.is_empty() {
2641 snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
2642 }
2643 cluster = vec![value];
2644 }
2645 }
2646 if !cluster.is_empty() {
2647 snapped.push(cluster.iter().sum::<f32>() / cluster.len() as f32);
2648 }
2649 if descending {
2650 snapped.sort_by(|a, b| b.total_cmp(a));
2651 }
2652 snapped
2653}
2654
2655fn strictly_ordered(values: &[f32], descending: bool) -> bool {
2656 values.windows(2).all(|pair| {
2657 pair[0].is_finite()
2658 && pair[1].is_finite()
2659 && if descending {
2660 pair[0] > pair[1]
2661 } else {
2662 pair[0] < pair[1]
2663 }
2664 })
2665}
2666
2667fn normalized_rect_edges(rect: &PdfRect) -> (f32, f32, f32, f32) {
2668 let x2 = rect.x + rect.width;
2669 let y2 = rect.y + rect.height;
2670 (
2671 rect.x.min(x2),
2672 rect.y.min(y2),
2673 rect.x.max(x2),
2674 rect.y.max(y2),
2675 )
2676}
2677
2678fn vector_geometry_bounds(rects: &[PdfRect], lines: &[PdfLine]) -> Option<RegionBounds> {
2679 let mut bounds: Option<RegionBounds> = None;
2680 let mut include = |x1: f32, y1: f32, x2: f32, y2: f32| {
2681 let next = RegionBounds {
2682 x_min: x1.min(x2),
2683 y_min: y1.min(y2),
2684 x_max: x1.max(x2),
2685 y_max: y1.max(y2),
2686 };
2687 bounds = Some(if let Some(prev) = bounds {
2688 RegionBounds {
2689 x_min: prev.x_min.min(next.x_min),
2690 y_min: prev.y_min.min(next.y_min),
2691 x_max: prev.x_max.max(next.x_max),
2692 y_max: prev.y_max.max(next.y_max),
2693 }
2694 } else {
2695 next
2696 });
2697 };
2698
2699 for rect in rects {
2700 let (x1, y1, x2, y2) = normalized_rect_edges(rect);
2701 include(x1, y1, x2, y2);
2702 }
2703 for line in lines {
2704 include(line.x1, line.y1, line.x2, line.y2);
2705 }
2706
2707 bounds
2708}
2709
2710fn extracted_cell_to_crop_px(
2711 bbox: [f32; 4],
2712 crop_pdf_pt_bbox: [f32; 4],
2713 render_dpi: f32,
2714 page_height: f32,
2715 coord_space: RegionCoordSpace,
2716) -> Option<[f32; 4]> {
2717 let [x1, y1, x2, y2] = extracted_bbox_to_page_top_left(bbox, page_height, coord_space);
2718 if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
2719 return None;
2720 }
2721 if x1 >= x2 || y1 >= y2 {
2722 return None;
2723 }
2724
2725 let ppi = if render_dpi > 0.0 {
2726 render_dpi / 72.0
2727 } else {
2728 1.0
2729 };
2730 let [crop_x1, crop_y1, _, _] = crop_pdf_pt_bbox;
2731 Some([
2732 (x1 - crop_x1) * ppi,
2733 (y1 - crop_y1) * ppi,
2734 (x2 - crop_x1) * ppi,
2735 (y2 - crop_y1) * ppi,
2736 ])
2737}
2738
2739fn extracted_bbox_to_page_top_left(
2740 bbox: [f32; 4],
2741 page_height: f32,
2742 coord_space: RegionCoordSpace,
2743) -> [f32; 4] {
2744 let [x1, y1, x2, y2] = bbox;
2745 let x_min = x1.min(x2);
2746 let x_max = x1.max(x2);
2747 let y_min = y1.min(y2);
2748 let y_max = y1.max(y2);
2749
2750 match coord_space {
2751 RegionCoordSpace::Standard => [x_min, page_height - y_max, x_max, page_height - y_min],
2752 RegionCoordSpace::Rotated90Ccw => {
2753 [-y_max, page_height - x_max, -y_min, page_height - x_min]
2754 }
2755 RegionCoordSpace::Rotated90Cw => [y_min, page_height + x_min, y_max, page_height + x_max],
2756 }
2757}
2758
2759#[derive(Debug, Clone)]
2771pub struct TsrTableInput {
2772 pub page: u32,
2774 pub crop_pdf_pt_bbox: [f32; 4],
2778 pub render_dpi: f32,
2781 pub structure_tokens: Vec<String>,
2784 pub cell_bboxes: Vec<Vec<f32>>,
2788}
2789
2790pub fn extract_tables_with_structure_cells_mem(
2811 buffer: &[u8],
2812 inputs: &[TsrTableInput],
2813) -> Result<Vec<Vec<tables::StructuredCell>>, PdfError> {
2814 use tables::structured::{
2815 cell_px_to_page_pt, normalize_cell_bands, parse_structure, polygon_to_aabb, StructuredCell,
2816 };
2817
2818 validate_pdf_bytes(buffer)?;
2819 let (doc, _page_count) = load_document_from_mem(buffer)?;
2820 let pages = doc.get_pages();
2821
2822 let needed_pages: HashSet<u32> = inputs.iter().map(|t| t.page + 1).collect();
2823 let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
2824
2825 let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
2826 let mut page_heights: HashMap<u32, f32> = HashMap::new();
2827 let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
2828 let mut rotated_pages: HashMap<u32, RegionCoordSpace> = HashMap::new();
2829 let mut style_cache = extractor::FontStyleCache::new();
2830
2831 for (page_num, &page_id) in pages.iter() {
2832 if !needed_pages.contains(page_num) {
2833 continue;
2834 }
2835 let extractor::PageBoxExtraction {
2836 mut items,
2837 coords_rotated,
2838 page_box,
2839 ..
2840 } = extractor::extract_page_text_items_in_page_box(
2841 &doc,
2842 page_id,
2843 *page_num,
2844 &font_cmaps,
2845 false,
2846 &mut style_cache,
2847 &mut extractor::FormWalkBudget::new(),
2848 )?;
2849 page_heights.insert(*page_num, page_box.height());
2850 let threshold = text_utils::fix_letterspaced_items(&mut items);
2851 if threshold > 0.10 {
2852 page_thresholds.insert(*page_num, threshold);
2853 }
2854 if coords_rotated != extractor::geometry::PageRotation::Upright {
2855 rotated_pages.insert(*page_num, coords_rotated.into());
2856 }
2857 items_by_page.insert(*page_num, items);
2858 }
2859
2860 let mut results: Vec<Vec<StructuredCell>> = Vec::with_capacity(inputs.len());
2861
2862 for input in inputs {
2863 let page_1idx = input.page + 1;
2864 let Some(items) = items_by_page.get(&page_1idx) else {
2865 results.push(Vec::new());
2867 continue;
2868 };
2869 let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
2870 let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
2871 let coords = rotated_pages
2872 .get(&page_1idx)
2873 .copied()
2874 .unwrap_or(RegionCoordSpace::Standard);
2875
2876 let crop_origin = [input.crop_pdf_pt_bbox[0], input.crop_pdf_pt_bbox[1]];
2877
2878 let slots = parse_structure(&input.structure_tokens);
2879 if slots.is_empty() {
2880 results.push(Vec::new());
2881 continue;
2882 }
2883
2884 let mut cells: Vec<StructuredCell> = Vec::with_capacity(slots.len());
2885 for slot in &slots {
2886 let page_pt_bbox;
2887
2888 if let Some(coords_arr) = input.cell_bboxes.get(slot.bbox_idx) {
2889 if let Some(aabb_px) = polygon_to_aabb(coords_arr) {
2890 page_pt_bbox = cell_px_to_page_pt(aabb_px, input.render_dpi, crop_origin);
2891 } else {
2892 page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
2893 }
2894 } else {
2895 page_pt_bbox = [0.0, 0.0, 0.0, 0.0];
2896 }
2897
2898 cells.push(StructuredCell {
2899 row: slot.row,
2900 col: slot.col,
2901 rowspan: slot.rowspan,
2902 colspan: slot.colspan,
2903 is_header: slot.is_header,
2904 text: String::new(),
2905 page_pt_bbox,
2906 });
2907 }
2908
2909 normalize_cell_bands(&mut cells);
2910
2911 let cell_meta: Vec<Option<(RegionBounds, f32, f32)>> = cells
2938 .iter()
2939 .map(|cell| {
2940 let [x1, y1, x2, y2] = cell.page_pt_bbox;
2941 if x1 >= x2 || y1 >= y2 {
2942 return None;
2943 }
2944 let bounds = region_bounds(x1, y1, x2, y2, page_h, coords);
2945 let cx = (bounds.x_min + bounds.x_max) * 0.5;
2946 let cy = (bounds.y_min + bounds.y_max) * 0.5;
2947 Some((bounds, cx, cy))
2948 })
2949 .collect();
2950
2951 let mut per_cell_items: Vec<Vec<TextItem>> = vec![Vec::new(); cells.len()];
2952 let mut orphan_token_subitems: Vec<TextItem> = Vec::new();
2959
2960 for item in items.iter() {
2961 let token_subitems = split_item_into_token_subitems(item);
2962 for token_item in token_subitems {
2963 let token_w = text_utils::effective_width(&token_item);
2964 let token_cx = token_item.x + token_w * 0.5;
2965 let token_cy = token_item.y + text_utils::effective_height(&token_item) * 0.5;
2966 let mut best: Option<(usize, f32)> = None;
2967 for (cell_idx, meta) in cell_meta.iter().enumerate() {
2968 let Some((bounds, ccx, ccy)) = meta else {
2969 continue;
2970 };
2971 if !tsr_region_contains_item(&token_item, *bounds) {
2972 continue;
2973 }
2974 let dx = token_cx - ccx;
2975 let dy = token_cy - ccy;
2976 let dist_sq = dx * dx + dy * dy;
2977 if best.is_none_or(|(_, d)| dist_sq < d) {
2978 best = Some((cell_idx, dist_sq));
2979 }
2980 }
2981 if let Some((ci, _)) = best {
2982 per_cell_items[ci].push(token_item);
2983 } else {
2984 orphan_token_subitems.push(token_item);
2985 }
2986 }
2987 }
2988
2989 for (cell_idx, matched) in per_cell_items.into_iter().enumerate() {
2992 cells[cell_idx].text = collect_text_from_matched_items(matched, adaptive_threshold)
2993 .replace(['\n', '\r'], " ");
2994 }
2995
2996 tsr_assign_orphan_items(
3010 &orphan_token_subitems,
3011 &mut cells,
3012 &std::collections::HashSet::new(),
3013 page_h,
3014 coords,
3015 );
3016
3017 results.push(cells);
3018 }
3019
3020 Ok(results)
3021}
3022
3023fn split_item_into_token_subitems(item: &TextItem) -> Vec<TextItem> {
3045 let total_chars = item.text.chars().count();
3046 if total_chars == 0 {
3047 return Vec::new();
3048 }
3049 if !item.is_upright() {
3054 return vec![item.clone()];
3055 }
3056 let item_w = if item.width > 0.0 || item.advance_known {
3057 item.width
3058 } else {
3059 total_chars as f32 * item.font_size * 0.5
3060 };
3061 let char_w = item_w / total_chars as f32;
3062
3063 let mut tokens: Vec<TextItem> = Vec::new();
3064 let mut current_token = String::new();
3065 let mut current_start_idx: Option<usize> = None;
3066
3067 let push_token =
3068 |tokens: &mut Vec<TextItem>, text: String, start_idx: usize, end_idx: usize| {
3069 if text.is_empty() {
3070 return;
3071 }
3072 let mut sub = item.clone();
3073 sub.text = text;
3074 sub.x = item.x + start_idx as f32 * char_w;
3075 sub.width = (end_idx - start_idx) as f32 * char_w;
3076 tokens.push(sub);
3077 };
3078
3079 for (idx, ch) in item.text.chars().enumerate() {
3080 if ch.is_whitespace() {
3081 if let Some(start_idx) = current_start_idx.take() {
3082 let text = std::mem::take(&mut current_token);
3083 push_token(&mut tokens, text, start_idx, idx);
3084 }
3085 } else {
3086 if current_start_idx.is_none() {
3087 current_start_idx = Some(idx);
3088 }
3089 current_token.push(ch);
3090 }
3091 }
3092 if let Some(start_idx) = current_start_idx {
3093 let text = std::mem::take(&mut current_token);
3094 push_token(&mut tokens, text, start_idx, total_chars);
3095 }
3096
3097 tokens
3098}
3099
3100fn tsr_assignment_caps(cells: &[tables::StructuredCell]) -> (f32, f32) {
3109 let mut widths: Vec<f32> = Vec::with_capacity(cells.len());
3110 let mut heights: Vec<f32> = Vec::with_capacity(cells.len());
3111 for cell in cells {
3112 let [x1, y1, x2, y2] = cell.page_pt_bbox;
3113 let w = (x2 - x1).abs();
3114 let h = (y2 - y1).abs();
3115 if w > 0.0 && h > 0.0 {
3116 widths.push(w);
3117 heights.push(h);
3118 }
3119 }
3120 if widths.is_empty() {
3121 return (0.0, 0.0);
3122 }
3123 widths.sort_by(|a, b| a.total_cmp(b));
3124 heights.sort_by(|a, b| a.total_cmp(b));
3125 let median_w = widths[widths.len() / 2];
3126 let median_h = heights[heights.len() / 2];
3127 let cap_x = median_w.max(5.0);
3131 let cap_y = median_h.max(5.0);
3132 (cap_x, cap_y)
3133}
3134
3135fn tsr_assign_orphan_items(
3146 items: &[TextItem],
3147 cells: &mut [tables::StructuredCell],
3148 claimed: &std::collections::HashSet<usize>,
3149 page_height: f32,
3150 coord_space: RegionCoordSpace,
3151) {
3152 if cells.is_empty() {
3153 return;
3154 }
3155 let (cap_x, cap_y) = tsr_assignment_caps(cells);
3156 if cap_x <= 0.0 || cap_y <= 0.0 {
3157 return;
3158 }
3159 let (cap_x, cap_y) = match coord_space {
3163 RegionCoordSpace::Standard => (cap_x, cap_y),
3164 RegionCoordSpace::Rotated90Ccw | RegionCoordSpace::Rotated90Cw => (cap_y, cap_x),
3165 };
3166 let y_tolerance = (cap_y * 0.5).max(3.0);
3173
3174 let cell_bounds: Vec<Option<RegionBounds>> = cells
3177 .iter()
3178 .map(|cell| {
3179 if !cell.text.is_empty() {
3180 return None;
3181 }
3182 let [x1, y1, x2, y2] = cell.page_pt_bbox;
3183 if x1 >= x2 || y1 >= y2 {
3184 return None;
3185 }
3186 Some(region_bounds(x1, y1, x2, y2, page_height, coord_space))
3187 })
3188 .collect();
3189
3190 let mut stage2_first_y: std::collections::HashMap<usize, f32> =
3193 std::collections::HashMap::new();
3194
3195 for (i, item) in items.iter().enumerate() {
3196 if claimed.contains(&i) {
3197 continue;
3198 }
3199 let item_w = text_utils::effective_width(item);
3200 if item.text.trim().is_empty() {
3201 continue;
3202 }
3203 let cx = item.x + item_w * 0.5;
3204 let cy = item.y + text_utils::effective_height(item) * 0.5;
3205
3206 let mut best: Option<(usize, f32)> = None;
3207 for (ci, bounds_opt) in cell_bounds.iter().enumerate() {
3208 let Some(bounds) = bounds_opt else {
3209 continue;
3210 };
3211 if let Some(&first_y) = stage2_first_y.get(&ci) {
3215 if (first_y - cy).abs() > y_tolerance {
3216 continue;
3217 }
3218 }
3219 let dx = (bounds.x_min - cx).max(0.0).max(cx - bounds.x_max);
3220 let dy = (bounds.y_min - cy).max(0.0).max(cy - bounds.y_max);
3221 if dx > cap_x || dy > cap_y {
3222 continue;
3223 }
3224 let dist_sq = dx * dx + dy * dy;
3225 if best.is_none_or(|(_, d)| dist_sq < d) {
3226 best = Some((ci, dist_sq));
3227 }
3228 }
3229
3230 if let Some((ci, _)) = best {
3231 let trimmed = item.text.trim();
3235 if cells[ci].text.is_empty() {
3236 cells[ci].text = trimmed.to_string();
3237 } else {
3238 cells[ci].text.push(' ');
3239 cells[ci].text.push_str(trimmed);
3240 }
3241 stage2_first_y.entry(ci).or_insert(cy);
3242 }
3243 }
3244}
3245
3246pub fn extract_tables_with_structure_mem(
3254 buffer: &[u8],
3255 inputs: &[TsrTableInput],
3256) -> Result<Vec<String>, PdfError> {
3257 let cells_lists = extract_tables_with_structure_cells_mem(buffer, inputs)?;
3258 Ok(cells_lists
3259 .into_iter()
3260 .map(|cells| {
3261 if cells.is_empty() {
3262 String::new()
3263 } else {
3264 tables::cells_to_markdown(&cells)
3265 }
3266 })
3267 .collect())
3268}
3269
3270#[derive(Debug, Clone)]
3279pub struct TableExtractionResult {
3280 pub markdown: String,
3281 pub fallback_reason: Option<String>,
3282}
3283
3284#[derive(Debug, Clone)]
3285enum TsrQualityIssue {
3286 PhantomEmptyRow,
3287 MultiRowInCell {
3288 expanded_cells: Option<Vec<tables::StructuredCell>>,
3289 },
3290}
3291
3292impl TsrQualityIssue {
3293 fn reason(&self) -> &'static str {
3294 match self {
3295 Self::PhantomEmptyRow => "phantom_empty_row",
3296 Self::MultiRowInCell { .. } => "multi_row_in_cell",
3297 }
3298 }
3299}
3300
3301#[derive(Clone)]
3302struct TsrCellTextLine {
3303 center_y: f32,
3304 half_height: f32,
3305 items: Vec<TextItem>,
3306}
3307
3308impl TsrCellTextLine {
3309 fn new(item: TextItem) -> Self {
3310 let center_y = item.y + text_utils::effective_height(&item) * 0.5;
3311 let half_height = (text_utils::effective_height(&item) * 0.5).max(2.5);
3312 Self {
3313 center_y,
3314 half_height,
3315 items: vec![item],
3316 }
3317 }
3318
3319 fn add(&mut self, item: TextItem) {
3320 let center_y = item.y + text_utils::effective_height(&item) * 0.5;
3321 let existing = self.items.len() as f32;
3322 self.center_y = (self.center_y * existing + center_y) / (existing + 1.0);
3323 self.half_height = self
3324 .half_height
3325 .max((text_utils::effective_height(&item) * 0.5).max(2.5));
3326 self.items.push(item);
3327 }
3328
3329 fn bottom_y(&self) -> f32 {
3330 self.items
3331 .iter()
3332 .map(|item| item.y)
3333 .fold(f32::INFINITY, f32::min)
3334 }
3335}
3336
3337#[derive(Clone)]
3338struct TsrRowExpansion {
3339 bands: Vec<f32>,
3340 tolerance: f32,
3341}
3342
3343fn collect_items_in_tsr_cell(
3344 items: &[TextItem],
3345 cell: &tables::StructuredCell,
3346 page_height: f32,
3347 coord_space: RegionCoordSpace,
3348) -> Vec<TextItem> {
3349 let [x1, y1, x2, y2] = cell.page_pt_bbox;
3350 if x1 >= x2 || y1 >= y2 {
3351 return Vec::new();
3352 }
3353 let bounds = region_bounds(x1, y1, x2, y2, page_height, coord_space);
3354 items
3355 .iter()
3356 .filter(|item| !item.text.trim().is_empty() && tsr_region_contains_item(item, bounds))
3357 .cloned()
3358 .collect()
3359}
3360
3361fn cluster_tsr_cell_text_lines(mut items: Vec<TextItem>) -> Vec<TsrCellTextLine> {
3362 if items.is_empty() {
3363 return Vec::new();
3364 }
3365
3366 items.sort_by(|a, b| {
3367 let ay = a.y + a.height * 0.5;
3368 let by = b.y + b.height * 0.5;
3369 by.total_cmp(&ay).then(a.x.total_cmp(&b.x))
3370 });
3371
3372 let mut lines: Vec<TsrCellTextLine> = Vec::new();
3373 for item in items {
3374 let item_top = item.y + text_utils::effective_height(&item);
3375 let item_half_height = (text_utils::effective_height(&item) * 0.5).max(2.5);
3376 if let Some(last) = lines.last_mut() {
3377 let gap = last.bottom_y() - item_top;
3378 if gap <= last.half_height.max(item_half_height) {
3379 last.add(item);
3380 continue;
3381 }
3382 }
3383 lines.push(TsrCellTextLine::new(item));
3384 }
3385
3386 lines
3387}
3388
3389fn build_tsr_row_expansion(
3390 row_cells: &[usize],
3391 cells: &[tables::StructuredCell],
3392 cell_lines: &[Vec<TsrCellTextLine>],
3393) -> Option<TsrRowExpansion> {
3394 if row_cells.is_empty() {
3395 return None;
3396 }
3397 let has_spanning_cell = row_cells
3398 .iter()
3399 .any(|&idx| cells[idx].rowspan > 1 || cells[idx].colspan > 1);
3400 if has_spanning_cell {
3401 return None;
3402 }
3403
3404 let multiline_cells = row_cells
3405 .iter()
3406 .filter(|&&idx| cell_lines[idx].len() >= 2)
3407 .count();
3408 if row_cells.len() >= 2 && multiline_cells < 2 {
3409 return None;
3410 }
3411 if row_cells.len() == 1 && multiline_cells == 0 {
3412 return None;
3413 }
3414
3415 let mut centers: Vec<(f32, f32)> = row_cells
3416 .iter()
3417 .flat_map(|&idx| {
3418 cell_lines[idx]
3419 .iter()
3420 .map(|line| (line.center_y, line.half_height))
3421 })
3422 .collect();
3423 if centers.len() < 2 {
3424 return None;
3425 }
3426 centers.sort_by(|a, b| b.0.total_cmp(&a.0));
3427
3428 let mut half_heights: Vec<f32> = centers.iter().map(|(_, h)| *h).collect();
3429 half_heights.sort_by(|a, b| a.total_cmp(b));
3430 let tolerance = (half_heights[half_heights.len() / 2] * 0.8).max(3.0);
3431
3432 let mut bands: Vec<(f32, usize)> = Vec::new();
3433 for (center, _) in centers {
3434 if let Some((band_center, count)) = bands
3435 .iter_mut()
3436 .find(|(band_center, _)| (*band_center - center).abs() <= tolerance)
3437 {
3438 *band_center = (*band_center * *count as f32 + center) / (*count as f32 + 1.0);
3439 *count += 1;
3440 } else {
3441 bands.push((center, 1));
3442 }
3443 }
3444
3445 if !(2..=4).contains(&bands.len()) {
3448 return None;
3449 }
3450
3451 let min_support = if row_cells.len() >= 2 { 2 } else { 1 };
3452 let supported = bands.iter().all(|(band, _)| {
3453 row_cells
3454 .iter()
3455 .filter(|&&idx| {
3456 cell_lines[idx]
3457 .iter()
3458 .any(|line| (line.center_y - *band).abs() <= tolerance)
3459 })
3460 .count()
3461 >= min_support
3462 });
3463 if !supported {
3464 return None;
3465 }
3466
3467 bands.sort_by(|a, b| b.0.total_cmp(&a.0));
3468 Some(TsrRowExpansion {
3469 bands: bands.into_iter().map(|(center, _)| center).collect(),
3470 tolerance,
3471 })
3472}
3473
3474fn text_for_tsr_band(
3475 lines: &[TsrCellTextLine],
3476 band: f32,
3477 tolerance: f32,
3478 adaptive_threshold: f32,
3479) -> String {
3480 let mut matched: Vec<TextItem> = lines
3481 .iter()
3482 .filter(|line| (line.center_y - band).abs() <= tolerance)
3483 .flat_map(|line| line.items.iter().cloned())
3484 .collect();
3485 if matched.is_empty() {
3486 return String::new();
3487 }
3488 matched.sort_by(|a, b| b.y.total_cmp(&a.y).then(a.x.total_cmp(&b.x)));
3489 collect_text_from_matched_items(matched, adaptive_threshold).replace(['\n', '\r'], " ")
3490}
3491
3492fn slice_cell_bbox_for_expanded_row(
3493 cell: &tables::StructuredCell,
3494 row_idx: usize,
3495 row_count: usize,
3496) -> [f32; 4] {
3497 let mut bbox = cell.page_pt_bbox;
3498 let top = bbox[1].min(bbox[3]);
3499 let bottom = bbox[1].max(bbox[3]);
3500 let height = bottom - top;
3501 if height <= 0.0 || row_count == 0 {
3502 return bbox;
3503 }
3504 let step = height / row_count as f32;
3505 bbox[1] = top + step * row_idx as f32;
3506 bbox[3] = if row_idx + 1 == row_count {
3507 bottom
3508 } else {
3509 top + step * (row_idx + 1) as f32
3510 };
3511 bbox
3512}
3513
3514fn try_expand_multi_row_cells(
3515 cells: &[tables::StructuredCell],
3516 items: &[TextItem],
3517 page_height: f32,
3518 coord_space: RegionCoordSpace,
3519 adaptive_threshold: f32,
3520) -> Option<Vec<tables::StructuredCell>> {
3521 if cells.is_empty() {
3522 return None;
3523 }
3524
3525 let cell_lines: Vec<Vec<TsrCellTextLine>> = cells
3526 .iter()
3527 .map(|cell| {
3528 if cell.rowspan > 1 {
3529 Vec::new()
3530 } else {
3531 cluster_tsr_cell_text_lines(collect_items_in_tsr_cell(
3532 items,
3533 cell,
3534 page_height,
3535 coord_space,
3536 ))
3537 }
3538 })
3539 .collect();
3540
3541 let mut cells_by_row: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
3542 for (idx, cell) in cells.iter().enumerate() {
3543 cells_by_row.entry(cell.row).or_default().push(idx);
3544 }
3545
3546 let mut expansions: HashMap<usize, TsrRowExpansion> = HashMap::new();
3547 for (&row, row_cells) in &cells_by_row {
3548 let covered_by_rowspan = cells
3549 .iter()
3550 .any(|cell| cell.rowspan > 1 && cell.row <= row && row < cell.row + cell.rowspan);
3551 if covered_by_rowspan {
3552 continue;
3553 }
3554 if let Some(expansion) = build_tsr_row_expansion(row_cells, cells, &cell_lines) {
3555 expansions.insert(row, expansion);
3556 }
3557 }
3558
3559 if expansions.is_empty() {
3560 return None;
3561 }
3562
3563 let mut expanded = Vec::with_capacity(cells.len() + expansions.len());
3564 let mut row_shift = 0usize;
3565 for (&row, row_cells) in &cells_by_row {
3566 if let Some(expansion) = expansions.get(&row) {
3567 for (band_idx, band) in expansion.bands.iter().enumerate() {
3568 for &cell_idx in row_cells {
3569 let mut cell = cells[cell_idx].clone();
3570 cell.row = row + row_shift + band_idx;
3571 cell.rowspan = 1;
3572 cell.text = text_for_tsr_band(
3573 &cell_lines[cell_idx],
3574 *band,
3575 expansion.tolerance,
3576 adaptive_threshold,
3577 );
3578 cell.page_pt_bbox =
3579 slice_cell_bbox_for_expanded_row(&cell, band_idx, expansion.bands.len());
3580 expanded.push(cell);
3581 }
3582 }
3583 row_shift += expansion.bands.len() - 1;
3584 } else {
3585 for &cell_idx in row_cells {
3586 let mut cell = cells[cell_idx].clone();
3587 cell.row += row_shift;
3588 expanded.push(cell);
3589 }
3590 }
3591 }
3592
3593 let original_rows = cells
3594 .iter()
3595 .map(|cell| cell.row + cell.rowspan.max(1))
3596 .max()
3597 .unwrap_or(0);
3598 let expanded_rows = expanded
3599 .iter()
3600 .map(|cell| cell.row + cell.rowspan.max(1))
3601 .max()
3602 .unwrap_or(0);
3603 (expanded_rows > original_rows).then_some(expanded)
3604}
3605
3606fn detect_tsr_quality_issue(
3624 buffer: &[u8],
3625 input: &TsrTableInput,
3626 cells: &[tables::StructuredCell],
3627) -> Result<Option<TsrQualityIssue>, PdfError> {
3628 if cells.is_empty() {
3629 return Ok(None);
3630 }
3631
3632 let max_row = cells.iter().map(|c| c.row).max().unwrap_or(0);
3634 if max_row >= 2 {
3635 let mut row_has_content = vec![false; max_row + 1];
3636 for cell in cells {
3637 if !cell.text.trim().is_empty() {
3638 row_has_content[cell.row] = true;
3639 }
3640 }
3641 for r in 1..max_row {
3642 if !row_has_content[r] && row_has_content[r - 1] && row_has_content[r + 1] {
3643 return Ok(Some(TsrQualityIssue::PhantomEmptyRow));
3644 }
3645 }
3646 }
3647
3648 let (doc, _page_count) = load_document_from_mem(buffer)?;
3655 let pages = doc.get_pages();
3656 let page_1idx = input.page + 1;
3657 let Some(&page_id) = pages.get(&page_1idx) else {
3658 return Ok(None);
3659 };
3660 let mut needed: HashSet<u32> = HashSet::new();
3661 needed.insert(page_1idx);
3662 let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
3663 let extractor::PageBoxExtraction {
3664 mut items,
3665 coords_rotated,
3666 page_box,
3667 ..
3668 } = extractor::extract_page_text_items_in_page_box(
3669 &doc,
3670 page_id,
3671 page_1idx,
3672 &font_cmaps,
3673 false,
3674 &mut extractor::FontStyleCache::new(),
3675 &mut extractor::FormWalkBudget::new(),
3676 )?;
3677 let page_h = page_box.height();
3678 let adaptive_threshold = text_utils::fix_letterspaced_items(&mut items);
3679 let coords = RegionCoordSpace::from(coords_rotated);
3680 let expanded_cells =
3681 try_expand_multi_row_cells(cells, &items, page_h, coords, adaptive_threshold);
3682 let first_row = cells.iter().map(|cell| cell.row).min().unwrap_or(0);
3683 let first_col = cells.iter().map(|cell| cell.col).min().unwrap_or(0);
3684
3685 for cell in cells {
3686 if cell.rowspan > 1 {
3688 continue;
3689 }
3690 if cell.text.trim().is_empty() {
3691 continue;
3692 }
3693 let cell_items = collect_items_in_tsr_cell(&items, cell, page_h, coords);
3694 if cell_items.len() < 2 {
3695 continue;
3696 }
3697 if cluster_tsr_cell_text_lines(cell_items).len() < 2 {
3698 continue;
3699 }
3700 if expanded_cells.is_some() {
3701 return Ok(Some(TsrQualityIssue::MultiRowInCell { expanded_cells }));
3702 }
3703 if !is_wrapped_tsr_label_cell(cell, first_row, first_col) {
3704 return Ok(Some(TsrQualityIssue::MultiRowInCell {
3705 expanded_cells: None,
3706 }));
3707 }
3708 }
3709
3710 Ok(None)
3711}
3712
3713fn is_wrapped_tsr_label_cell(
3714 cell: &tables::StructuredCell,
3715 first_row: usize,
3716 first_col: usize,
3717) -> bool {
3718 cell.is_header || cell.row == first_row || cell.col == first_col
3719}
3720
3721pub fn extract_tables_with_structure_auto_mem(
3755 buffer: &[u8],
3756 inputs: &[TsrTableInput],
3757) -> Result<Vec<TableExtractionResult>, PdfError> {
3758 let tsr_cells = extract_tables_with_structure_cells_mem(buffer, inputs)?;
3759 let mut results = Vec::with_capacity(inputs.len());
3760
3761 for (i, input) in inputs.iter().enumerate() {
3762 let cells = &tsr_cells[i];
3763 let tsr_md = if cells.is_empty() {
3764 String::new()
3765 } else {
3766 tables::cells_to_markdown(cells)
3767 };
3768
3769 let issue = match detect_tsr_quality_issue(buffer, input, cells) {
3770 Ok(opt) => opt,
3771 Err(_) => {
3772 results.push(TableExtractionResult {
3776 markdown: tsr_md,
3777 fallback_reason: Some("detection_error".to_string()),
3778 });
3779 continue;
3780 }
3781 };
3782
3783 let result = match issue {
3784 None => TableExtractionResult {
3785 markdown: tsr_md,
3786 fallback_reason: None,
3787 },
3788 Some(issue) => {
3789 let reason = issue.reason().to_string();
3790 if let TsrQualityIssue::MultiRowInCell {
3791 expanded_cells: Some(expanded_cells),
3792 } = issue
3793 {
3794 let expanded_md = tables::cells_to_markdown(&expanded_cells);
3795 if !expanded_md.trim().is_empty() {
3796 results.push(TableExtractionResult {
3797 markdown: expanded_md,
3798 fallback_reason: Some("multi_row_in_cell_expanded".to_string()),
3799 });
3800 continue;
3801 }
3802 }
3803 let heuristic_md = match extract_tables_in_regions_mem(
3806 buffer,
3807 &[(input.page, vec![input.crop_pdf_pt_bbox])],
3808 ) {
3809 Ok(pages) => pages
3810 .into_iter()
3811 .next()
3812 .and_then(|p| p.regions.into_iter().next().map(|r| r.text))
3813 .unwrap_or_default(),
3814 Err(_) => {
3815 results.push(TableExtractionResult {
3817 markdown: tsr_md,
3818 fallback_reason: Some(format!("{reason}_heuristic_error")),
3819 });
3820 continue;
3821 }
3822 };
3823 if heuristic_md.trim().is_empty() {
3824 TableExtractionResult {
3828 markdown: tsr_md,
3829 fallback_reason: Some(format!("{reason}_heuristic_empty")),
3830 }
3831 } else {
3832 TableExtractionResult {
3833 markdown: heuristic_md,
3834 fallback_reason: Some(reason),
3835 }
3836 }
3837 }
3838 };
3839 results.push(result);
3840 }
3841
3842 Ok(results)
3843}
3844
3845#[derive(Clone, Copy, Debug, PartialEq, Eq)]
3850enum RegionCoordSpace {
3851 Standard,
3852 Rotated90Ccw,
3854 Rotated90Cw,
3856}
3857
3858impl From<extractor::geometry::PageRotation> for RegionCoordSpace {
3859 fn from(rotation: extractor::geometry::PageRotation) -> Self {
3860 use extractor::geometry::PageRotation;
3861 match rotation {
3862 PageRotation::Upright => RegionCoordSpace::Standard,
3863 PageRotation::Ccw => RegionCoordSpace::Rotated90Ccw,
3864 PageRotation::Cw => RegionCoordSpace::Rotated90Cw,
3865 }
3866 }
3867}
3868
3869fn sheet_region_rect(rect: [f32; 4], display: Option<extractor::DisplayPage>) -> [f32; 4] {
3873 match display {
3874 Some(page) => page.region_to_sheet(rect),
3875 None => rect,
3876 }
3877}
3878
3879#[derive(Clone, Copy)]
3880struct RegionBounds {
3881 x_min: f32,
3882 y_min: f32,
3883 x_max: f32,
3884 y_max: f32,
3885}
3886
3887pub fn collect_text_in_region(
3902 items: &[TextItem],
3903 rx1: f32,
3904 ry1: f32,
3905 rx2: f32,
3906 ry2: f32,
3907 page_height: f32,
3908) -> String {
3909 collect_text_in_region_with_options(
3910 items,
3911 rx1,
3912 ry1,
3913 rx2,
3914 ry2,
3915 page_height,
3916 infer_region_coord_space(items),
3917 0.10,
3918 )
3919}
3920
3921pub fn collect_text_in_region_in_frame(
3927 items: &[TextItem],
3928 rx1: f32,
3929 ry1: f32,
3930 rx2: f32,
3931 ry2: f32,
3932 page_height: f32,
3933 rotation: PageRotation,
3934) -> String {
3935 collect_text_in_region_with_options(
3936 items,
3937 rx1,
3938 ry1,
3939 rx2,
3940 ry2,
3941 page_height,
3942 RegionCoordSpace::from(rotation),
3943 0.10,
3944 )
3945}
3946
3947#[allow(clippy::too_many_arguments)]
3948fn collect_text_in_region_with_options(
3949 items: &[TextItem],
3950 rx1: f32,
3951 ry1: f32,
3952 rx2: f32,
3953 ry2: f32,
3954 page_height: f32,
3955 coord_space: RegionCoordSpace,
3956 adaptive_threshold: f32,
3957) -> String {
3958 let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
3959 let matched: Vec<TextItem> = items
3960 .iter()
3961 .filter(|item| region_overlaps_item(item, bounds))
3962 .cloned()
3963 .collect();
3964 collect_text_from_matched_items(matched, adaptive_threshold)
3965}
3966
3967#[cfg(test)]
3968#[allow(clippy::too_many_arguments)]
3969fn collect_text_in_tsr_cell(
3970 items: &[TextItem],
3971 rx1: f32,
3972 ry1: f32,
3973 rx2: f32,
3974 ry2: f32,
3975 page_height: f32,
3976 coord_space: RegionCoordSpace,
3977 adaptive_threshold: f32,
3978) -> String {
3979 let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
3980 let matched: Vec<TextItem> = items
3981 .iter()
3982 .filter(|item| tsr_region_contains_item(item, bounds))
3983 .cloned()
3984 .collect();
3985 collect_text_from_matched_items(matched, adaptive_threshold)
3986}
3987
3988fn collect_text_from_matched_items(matched: Vec<TextItem>, adaptive_threshold: f32) -> String {
3989 if matched.is_empty() {
3990 return String::new();
3991 }
3992
3993 let mut sorted = matched;
4001 sorted.sort_by(|a, b| b.line_y().total_cmp(&a.line_y()).then(a.x.total_cmp(&b.x)));
4002 let region_rtl = text_utils::is_rtl_text(sorted.iter().map(|i| &i.text));
4003
4004 let y_tolerance = 3.0;
4005 let mut lines: Vec<extractor::TextLine> = Vec::new();
4006
4007 for item in sorted {
4008 let should_merge = lines.last().is_some_and(|last_line: &extractor::TextLine| {
4009 last_line.page == item.page && (last_line.y - item.line_y()).abs() < y_tolerance
4010 });
4011 if should_merge {
4012 lines.last_mut().unwrap().items.push(item);
4013 } else {
4014 let y = item.line_y();
4015 let page = item.page;
4016 lines.push(extractor::TextLine {
4017 items: vec![item],
4018 y,
4019 page,
4020 adaptive_threshold,
4021 });
4022 }
4023 }
4024
4025 for line in &mut lines {
4027 text_utils::sort_line_items(&mut line.items, region_rtl);
4028 }
4029
4030 lines
4031 .into_iter()
4032 .map(|line| line.text())
4033 .collect::<Vec<_>>()
4034 .join("\n")
4035}
4036
4037fn infer_region_coord_space(items: &[TextItem]) -> RegionCoordSpace {
4038 let negative_y = items.iter().filter(|item| item.y < 0.0).count();
4051 if !items.is_empty() && negative_y * 2 >= items.len() {
4052 RegionCoordSpace::Rotated90Ccw
4053 } else {
4054 RegionCoordSpace::Standard
4055 }
4056}
4057
4058fn region_bounds(
4062 rx1: f32,
4063 ry1: f32,
4064 rx2: f32,
4065 ry2: f32,
4066 page_height: f32,
4067 coord_space: RegionCoordSpace,
4068) -> RegionBounds {
4069 let tx_min = rx1.min(rx2);
4070 let tx_max = rx1.max(rx2);
4071 let ty_min = ry1.min(ry2);
4072 let ty_max = ry1.max(ry2);
4073 let by_min = page_height - ty_max;
4074 let by_max = page_height - ty_min;
4075 match coord_space {
4076 RegionCoordSpace::Standard => RegionBounds {
4077 x_min: tx_min,
4078 y_min: by_min,
4079 x_max: tx_max,
4080 y_max: by_max,
4081 },
4082 RegionCoordSpace::Rotated90Ccw => RegionBounds {
4083 x_min: by_min,
4084 x_max: by_max,
4085 y_min: -tx_max,
4086 y_max: -tx_min,
4087 },
4088 RegionCoordSpace::Rotated90Cw => RegionBounds {
4089 x_min: -by_max,
4090 x_max: -by_min,
4091 y_min: tx_min,
4092 y_max: tx_max,
4093 },
4094 }
4095}
4096
4097const REGION_MARGIN: f32 = 1.5;
4101
4102fn region_item_overlap_area(item: &TextItem, bounds: RegionBounds) -> f32 {
4105 let item_x_max = item.x + text_utils::effective_width(item);
4106 let item_y_max = item.y + text_utils::effective_height(item);
4107 let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
4108 - item.x.max(bounds.x_min - REGION_MARGIN))
4109 .max(0.0);
4110 let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
4111 - item.y.max(bounds.y_min - REGION_MARGIN))
4112 .max(0.0);
4113 x_overlap * y_overlap
4114}
4115
4116fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
4117 let item_x_min = item.x;
4118 let item_x_max = item.x + text_utils::effective_width(item);
4119 let item_y_min = item.y;
4120 let item_y_max = item.y + text_utils::effective_height(item);
4121
4122 let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
4123 - item_x_min.max(bounds.x_min - REGION_MARGIN))
4124 .max(0.0);
4125 let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
4126 - item_y_min.max(bounds.y_min - REGION_MARGIN))
4127 .max(0.0);
4128 x_overlap > 0.0 && y_overlap > 0.0
4129}
4130
4131fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
4132 let (x_min, y_min, x_max, y_max) = normalized_rect_edges(rect);
4133 ranges_overlap(
4134 x_min,
4135 x_max,
4136 bounds.x_min - REGION_MARGIN,
4137 bounds.x_max + REGION_MARGIN,
4138 ) && ranges_overlap(
4139 y_min,
4140 y_max,
4141 bounds.y_min - REGION_MARGIN,
4142 bounds.y_max + REGION_MARGIN,
4143 )
4144}
4145
4146fn region_overlaps_line(line: &PdfLine, bounds: RegionBounds) -> bool {
4147 let x_min = line.x1.min(line.x2);
4148 let x_max = line.x1.max(line.x2);
4149 let y_min = line.y1.min(line.y2);
4150 let y_max = line.y1.max(line.y2);
4151 ranges_overlap(
4152 x_min,
4153 x_max,
4154 bounds.x_min - REGION_MARGIN,
4155 bounds.x_max + REGION_MARGIN,
4156 ) && ranges_overlap(
4157 y_min,
4158 y_max,
4159 bounds.y_min - REGION_MARGIN,
4160 bounds.y_max + REGION_MARGIN,
4161 )
4162}
4163
4164fn ranges_overlap(a_min: f32, a_max: f32, b_min: f32, b_max: f32) -> bool {
4165 a_max >= b_min && b_max >= a_min
4166}
4167
4168fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
4169 let item_x_min = item.x;
4170 let item_x_max = item.x + text_utils::effective_width(item);
4171 let item_y_min = item.y;
4172 let item_y_max = item.y + text_utils::effective_height(item);
4173
4174 let center_x = (item_x_min + item_x_max) * 0.5;
4175 let center_y = (item_y_min + item_y_max) * 0.5;
4176 if center_x >= bounds.x_min
4177 && center_x <= bounds.x_max
4178 && center_y >= bounds.y_min
4179 && center_y <= bounds.y_max
4180 {
4181 return true;
4182 }
4183
4184 let x_overlap = (item_x_max.min(bounds.x_max) - item_x_min.max(bounds.x_min)).max(0.0);
4185 let y_overlap = (item_y_max.min(bounds.y_max) - item_y_min.max(bounds.y_min)).max(0.0);
4186 let item_width = (item_x_max - item_x_min).max(0.1);
4187 let item_height = (item_y_max - item_y_min).max(0.1);
4188
4189 x_overlap / item_width >= 0.6 && y_overlap / item_height >= 0.6
4190}
4191
4192pub(crate) fn load_document_from_path<P: AsRef<Path>>(
4202 path: P,
4203) -> Result<(Document, u32), PdfError> {
4204 load_document_from_path_with_password(path, None)
4205}
4206
4207pub(crate) fn load_document_from_path_with_password<P: AsRef<Path>>(
4209 path: P,
4210 password: Option<&str>,
4211) -> Result<(Document, u32), PdfError> {
4212 let buffer = read_file(path.as_ref())?;
4213 load_document_from_mem_with_password(&buffer, password)
4214}
4215
4216pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
4218 load_document_from_mem_with_password(buffer, None)
4219}
4220
4221pub(crate) fn load_document_from_mem_with_password(
4223 buffer: &[u8],
4224 password: Option<&str>,
4225) -> Result<(Document, u32), PdfError> {
4226 load_document_from_mem_with_repairs(buffer, password)
4227 .map(|(doc, page_count, _)| (doc, page_count))
4228}
4229
4230#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
4233pub(crate) struct LoadRepairs {
4234 pub(crate) widened_form_bboxes: usize,
4237 pub(crate) saturated_bbox_numerals: usize,
4240}
4241
4242impl LoadRepairs {
4243 pub(crate) fn repaired_forms(&self) -> bool {
4246 self.widened_form_bboxes > 0 || self.saturated_bbox_numerals > 0
4247 }
4248}
4249
4250pub(crate) fn load_document_from_mem_with_repairs(
4253 buffer: &[u8],
4254 password: Option<&str>,
4255) -> Result<(Document, u32, LoadRepairs), PdfError> {
4256 let buffer = strip_leading_bytes_before_header(buffer);
4265
4266 let fixed = structure_tree::fix_bare_struct_names(buffer);
4270 let buf = fixed.as_ref();
4271
4272 match load_document_bytes(buf, password) {
4273 Ok(doc) => {
4274 let (doc, saturated) = reload_after_saturating_bbox_numerals(doc, buf, password);
4275 finish_loaded_document(doc, saturated)
4276 }
4277 Err(first_err) => {
4278 for repaired in repair_pdf_container_candidates(buf) {
4279 match load_document_bytes(&repaired, password) {
4280 Ok(doc) => {
4281 log::debug!("loaded PDF after repairing malformed container bytes");
4282 let (doc, saturated) =
4283 reload_after_saturating_bbox_numerals(doc, &repaired, password);
4284 return finish_loaded_document(doc, saturated);
4285 }
4286 Err(e) => {
4287 if is_encrypted_lopdf_error(&e) {
4288 return Err(e.into());
4289 }
4290 }
4291 }
4292 }
4293 Err(first_err.into())
4294 }
4295 }
4296}
4297
4298fn reload_after_saturating_bbox_numerals(
4304 doc: Document,
4305 bytes: &[u8],
4306 password: Option<&str>,
4307) -> (Document, usize) {
4308 let (mut doc, count) = match overlong_numerals::saturate_overlong_bbox_numerals(bytes, &doc) {
4309 Some((rewritten, count)) => match load_document_bytes(&rewritten, password) {
4310 Ok(reloaded) if reloaded.objects.len() > doc.objects.len() => {
4311 log::debug!("loaded PDF after saturating {count} /BBox numeral(s) no parser holds");
4312 (reloaded, count)
4313 }
4314 _ => (doc, 0),
4315 },
4316 None => (doc, 0),
4317 };
4318 let in_object_streams =
4319 overlong_numerals::recover_referenced_bboxes_in_object_streams(&mut doc);
4320 if in_object_streams > 0 {
4321 log::debug!(
4322 "recovered /BBox array(s) from object streams after saturating {in_object_streams} \
4323 numeral(s) no parser holds"
4324 );
4325 }
4326 (doc, count + in_object_streams)
4327}
4328
4329fn finish_loaded_document(
4335 mut doc: Document,
4336 saturated_bbox_numerals: usize,
4337) -> Result<(Document, u32, LoadRepairs), PdfError> {
4338 let page_count = doc.get_pages().len() as u32;
4339 if page_count == 0 {
4340 return Err(PdfError::Parse(
4341 "document has no readable pages".to_string(),
4342 ));
4343 }
4344 let expected = doc.max_id as usize;
4353 let loaded = doc.objects.len();
4354 let missing = expected.saturating_sub(loaded);
4355 if missing > 1000 && missing.saturating_mul(64) > expected {
4356 log::warn!(
4357 "loaded {loaded} of {expected} xref object slots; the rest are free \
4358 xref entries or object streams skipped over the \
4359 {MAX_STREAM_DECOMPRESSED_BYTES}-byte decompression bound, and will \
4360 resolve as not-found"
4361 );
4362 }
4363 let repairs = LoadRepairs {
4364 widened_form_bboxes: form_bbox_repair::widen_degenerate_form_bboxes(&mut doc),
4365 saturated_bbox_numerals,
4366 };
4367 Ok((doc, page_count, repairs))
4368}
4369
4370pub(crate) const MAX_STREAM_DECOMPRESSED_BYTES: usize = 8 * 1024 * 1024;
4385
4386fn bounded_load_options() -> lopdf::LoadOptions {
4387 lopdf::LoadOptions {
4388 max_decompressed_size: Some(MAX_STREAM_DECOMPRESSED_BYTES),
4389 ..Default::default()
4390 }
4391}
4392
4393fn load_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
4394 match Document::load_mem_with_options(buf, bounded_load_options()) {
4395 Ok(doc) if doc.is_encrypted() => decrypt_document_bytes(buf, password),
4400 Ok(doc) => Ok(doc),
4401 Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
4402 Err(e) => Err(e),
4403 }
4404}
4405
4406fn decrypt_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
4410 let pw = password.unwrap_or("");
4411 let with_password = |pw: &str| lopdf::LoadOptions {
4412 password: Some(pw.to_string()),
4413 ..bounded_load_options()
4414 };
4415 match Document::load_mem_with_options(buf, with_password(pw)) {
4416 Ok(doc) => Ok(doc),
4417 Err(inner) if !pw.is_empty() => {
4418 Document::load_mem_with_options(buf, with_password("")).map_err(|_| inner)
4419 }
4420 Err(inner) => Err(inner),
4421 }
4422}
4423
4424fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
4425 let mut candidates = Vec::new();
4426
4427 add_repair_candidate(&mut candidates, append_missing_eof_marker(buf), buf);
4428 add_repair_candidate(&mut candidates, recover_startxref_pointer(buf), buf);
4429 add_repair_candidate(
4430 &mut candidates,
4431 xref_repair::rebuild_short_xref_entries(buf),
4432 buf,
4433 );
4434
4435 candidates
4436}
4437
4438fn recover_startxref_pointer(buf: &[u8]) -> Option<Vec<u8>> {
4458 let xref_pos = find_last_valid_xref_table_start(buf)?;
4459
4460 let mut repaired = Vec::with_capacity(buf.len() + 32);
4461 repaired.extend_from_slice(buf);
4462 if !repaired.ends_with(b"\n") {
4463 repaired.push(b'\n');
4464 }
4465 repaired.extend_from_slice(format!("startxref\n{xref_pos}\n%%EOF\n").as_bytes());
4466 Some(repaired)
4467}
4468
4469fn find_last_valid_xref_table_start(buf: &[u8]) -> Option<usize> {
4476 const KEYWORD: &[u8] = b"xref";
4477 if buf.len() < KEYWORD.len() {
4478 return None;
4479 }
4480 let mut pos = buf.len() - KEYWORD.len();
4481 loop {
4482 if &buf[pos..pos + KEYWORD.len()] == KEYWORD {
4483 let before_ok = pos == 0 || buf[pos - 1].is_ascii_whitespace();
4484 let after_ok = buf
4485 .get(pos + KEYWORD.len())
4486 .is_none_or(|c| c.is_ascii_whitespace());
4487 if before_ok && after_ok && looks_like_xref_subsection_header(buf, pos + KEYWORD.len())
4488 {
4489 return Some(pos);
4490 }
4491 }
4492 if pos == 0 {
4493 return None;
4494 }
4495 pos -= 1;
4496 }
4497}
4498
4499fn looks_like_xref_subsection_header(buf: &[u8], pos: usize) -> bool {
4503 fn skip_ws(buf: &[u8], mut pos: usize) -> usize {
4504 while buf.get(pos).is_some_and(u8::is_ascii_whitespace) {
4505 pos += 1;
4506 }
4507 pos
4508 }
4509 fn skip_digits(buf: &[u8], mut pos: usize) -> usize {
4510 while buf.get(pos).is_some_and(u8::is_ascii_digit) {
4511 pos += 1;
4512 }
4513 pos
4514 }
4515
4516 let pos = skip_ws(buf, pos);
4517 let after_first_digits = skip_digits(buf, pos);
4518 if after_first_digits == pos {
4519 return false; }
4521 let sep = skip_ws(buf, after_first_digits);
4522 if sep == after_first_digits {
4523 return false; }
4525 let after_count = skip_digits(buf, sep);
4526 if after_count == sep {
4527 return false; }
4529 buf.get(after_count).is_none_or(u8::is_ascii_whitespace)
4532}
4533
4534fn add_repair_candidate(
4535 candidates: &mut Vec<Vec<u8>>,
4536 candidate: Option<Vec<u8>>,
4537 original: &[u8],
4538) {
4539 let Some(candidate) = candidate else {
4540 return;
4541 };
4542 if candidate.as_slice() == original {
4543 return;
4544 }
4545 if candidates.iter().any(|existing| existing == &candidate) {
4546 return;
4547 }
4548 candidates.push(candidate);
4549}
4550
4551fn append_missing_eof_marker(buf: &[u8]) -> Option<Vec<u8>> {
4552 if contains_recent_eof_marker(buf) {
4553 return None;
4554 }
4555
4556 let mut end = buf.len();
4557 while end > 0 && buf[end - 1].is_ascii_whitespace() {
4558 end -= 1;
4559 }
4560
4561 if !buf[..end].ends_with(b"%%EO") {
4562 return None;
4563 }
4564
4565 let mut repaired = Vec::with_capacity(end + 2);
4566 repaired.extend_from_slice(&buf[..end]);
4567 repaired.extend_from_slice(b"F\n");
4568 Some(repaired)
4569}
4570
4571fn contains_recent_eof_marker(buf: &[u8]) -> bool {
4572 let start = buf.len().saturating_sub(1024);
4573 buf[start..].windows(b"%%EOF".len()).any(|w| w == b"%%EOF")
4574}
4575
4576fn process_document(
4578 doc: Document,
4579 page_count: u32,
4580 options: PdfOptions,
4581 start: ProcessingTimer,
4582) -> Result<PdfProcessResult, PdfError> {
4583 let detection = detector::detect_from_document(&doc, page_count, &options.detection)?;
4585 let pdf_type = detection.pdf_type;
4586 let pages_needing_ocr = detection.pages_needing_ocr;
4587 let title = detection.title;
4588 let author = detection.author;
4589 let subject = detection.subject;
4590 let keywords = detection.keywords;
4591 let creator = detection.creator;
4592 let producer = detection.producer;
4593 let creation_date = detection.creation_date;
4594 let mod_date = detection.mod_date;
4595 let confidence = detection.confidence;
4596 let detection_ocr_reasons = detection.ocr_reasons_by_page;
4597
4598 if options.mode == ProcessMode::DetectOnly {
4600 return Ok(PdfProcessResult {
4601 pdf_type,
4602 markdown: None,
4603 page_count,
4604 processing_time_ms: start.elapsed_ms(),
4605 pages_needing_ocr,
4606 ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
4607 title,
4608 author,
4609 subject,
4610 keywords,
4611 creator,
4612 producer,
4613 creation_date,
4614 mod_date,
4615 confidence,
4616 layout: LayoutComplexity::default(),
4617 has_encoding_issues: false,
4618 cmap_gaps: Vec::new(),
4619 });
4620 }
4621
4622 if matches!(pdf_type, PdfType::Scanned | PdfType::ImageBased) {
4624 return Ok(PdfProcessResult {
4625 pdf_type,
4626 markdown: None,
4627 page_count,
4628 processing_time_ms: start.elapsed_ms(),
4629 pages_needing_ocr,
4630 ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
4631 title,
4632 author,
4633 subject,
4634 keywords,
4635 creator,
4636 producer,
4637 creation_date,
4638 mod_date,
4639 confidence,
4640 layout: LayoutComplexity::default(),
4641 has_encoding_issues: false,
4642 cmap_gaps: Vec::new(),
4643 });
4644 }
4645
4646 let extracted = {
4648 let font_cmaps = FontCMaps::from_doc(&doc);
4649 let result = extractor::extract_positioned_text_with_folio_context(
4653 &doc,
4654 &font_cmaps,
4655 options.page_filter.as_ref(),
4656 );
4657
4658 if pdf_type == PdfType::Mixed {
4662 if let Ok((ref items, _, _)) = result.as_ref().map(|(e, _, _, _, _)| e) {
4663 let sample: String = items
4664 .iter()
4665 .filter(|item| {
4666 options
4667 .page_filter
4668 .as_ref()
4669 .is_none_or(|filter| filter.contains(&item.page))
4670 })
4671 .take(200)
4672 .map(|item| item.text.as_str())
4673 .collect();
4674 if is_garbage_text(&sample) || sample.trim().is_empty() {
4675 extractor::extract_positioned_text_include_invisible_with_folio_context(
4676 &doc,
4677 &font_cmaps,
4678 options.page_filter.as_ref(),
4679 )
4680 } else {
4681 result
4682 }
4683 } else {
4684 extractor::extract_positioned_text_include_invisible_with_folio_context(
4686 &doc,
4687 &font_cmaps,
4688 options.page_filter.as_ref(),
4689 )
4690 }
4691 } else {
4692 result
4693 }
4694 };
4695
4696 let extracted = if pdf_type == PdfType::Mixed {
4698 extracted.ok()
4699 } else {
4700 Some(extracted?)
4701 };
4702
4703 let (struct_roles, struct_tables) = structure_tree::StructTree::from_doc(&doc)
4705 .map(|tree| {
4706 let page_ids = doc.get_pages();
4707 let roles = tree.mcid_to_roles(&page_ids);
4708 let tables = tree.extract_tables(&page_ids);
4709 if !roles.is_empty() {
4710 log::debug!(
4711 "structure tree: {} pages with MCID roles, {} total MCIDs, {} tagged tables",
4712 roles.len(),
4713 tree.mcid_count(),
4714 tables.len()
4715 );
4716 }
4717 let roles = if roles.is_empty() { None } else { Some(roles) };
4718 (roles, tables)
4719 })
4720 .unwrap_or((None, Vec::new()));
4721
4722 let (
4723 markdown,
4724 layout,
4725 has_encoding_issues,
4726 gid_pages,
4727 text_quality_pages,
4728 text_quality_reasons_by_page,
4729 cmap_gaps,
4730 ) = match extracted {
4731 Some((
4732 (items, rects, lines),
4733 page_thresholds,
4734 gid_encoded_pages,
4735 _page_rotations,
4736 cmap_coverage,
4737 )) => {
4738 let mut ocr_reasons_by_page = BTreeMap::new();
4739
4740 let (items, rects, lines) =
4747 if pages_needing_ocr.is_empty() || pdf_type != PdfType::TextBased {
4748 (items, rects, lines)
4749 } else {
4750 let ocr_set: std::collections::HashSet<u32> =
4751 pages_needing_ocr.iter().copied().collect();
4752 let mut garbage_pages: std::collections::HashSet<u32> =
4754 std::collections::HashSet::new();
4755 for &pg in &ocr_set {
4756 if options
4757 .page_filter
4758 .as_ref()
4759 .is_some_and(|filter| !filter.contains(&pg))
4760 {
4761 continue;
4762 }
4763 let page_text: String = items
4764 .iter()
4765 .filter(|i| i.page == pg)
4766 .map(|i| i.text.as_str())
4767 .collect();
4768 if is_cid_garbage(&page_text) {
4769 garbage_pages.insert(pg);
4770 }
4771 }
4772 if garbage_pages.is_empty() {
4773 (items, rects, lines)
4774 } else {
4775 log::debug!(
4776 "suppressing garbage text from OCR-flagged pages: {:?}",
4777 garbage_pages
4778 );
4779 for page in &garbage_pages {
4780 add_ocr_reason(
4781 &mut ocr_reasons_by_page,
4782 *page,
4783 OCR_REASON_SUSPECTED_GARBLED_TEXT,
4784 );
4785 }
4786 let items: Vec<_> = items
4787 .into_iter()
4788 .filter(|i| !garbage_pages.contains(&i.page))
4789 .collect();
4790 let rects: Vec<_> = rects
4791 .into_iter()
4792 .filter(|r| !garbage_pages.contains(&r.page))
4793 .collect();
4794 let lines: Vec<_> = lines
4795 .into_iter()
4796 .filter(|l| !garbage_pages.contains(&l.page))
4797 .collect();
4798 (items, rects, lines)
4799 }
4800 };
4801
4802 let selected_page = |page: u32| {
4803 options
4804 .page_filter
4805 .as_ref()
4806 .is_none_or(|filter| filter.contains(&page))
4807 };
4808 let rects: Vec<_> = rects
4809 .into_iter()
4810 .filter(|rect| selected_page(rect.page))
4811 .collect();
4812 let lines: Vec<_> = lines
4813 .into_iter()
4814 .filter(|line| selected_page(line.page))
4815 .collect();
4816 let gid_encoded_pages: HashSet<_> = gid_encoded_pages
4817 .into_iter()
4818 .filter(|page| selected_page(*page))
4819 .collect();
4820 let FolioFilteredItems {
4821 items,
4822 layout_items,
4823 removal_mask,
4824 removed_pages,
4825 } = select_items_with_document_folio_context(
4826 items,
4827 page_count,
4828 options.page_filter.as_ref(),
4829 );
4830
4831 let text_quality = analyze_text_quality(&items);
4832 merge_ocr_reasons(&mut ocr_reasons_by_page, text_quality.reasons_by_page);
4833 let chart_regions = markdown::chart_regions_by_page(&items, &rects, &lines);
4834 let layout = compute_layout_complexity_with_chart_regions(
4835 &items,
4836 &layout_items,
4837 &rects,
4838 &lines,
4839 &chart_regions,
4840 );
4841
4842 let md = if options.mode == ProcessMode::Analyze {
4843 None
4844 } else {
4845 Some(markdown::to_markdown_from_items_with_rects_and_lines(
4846 items,
4847 options.markdown,
4848 &rects,
4849 &lines,
4850 markdown::MarkdownDocumentContext {
4851 page_thresholds: &page_thresholds,
4852 struct_roles: struct_roles.as_ref(),
4853 struct_tables: &struct_tables,
4854 page_count,
4855 prefiltered_page_number_pages: Some(&removed_pages),
4856 prefiltered_page_number_mask: Some(removal_mask.as_slice()),
4857 precomputed_chart_regions: Some(&chart_regions),
4858 },
4859 ))
4860 };
4861
4862 let cmap_unmapped = cmap_coverage.values().any(|stats| stats.unmapped > 0);
4866 let enc = !ocr_reasons_by_page.is_empty()
4867 || text_quality.has_encoding_issues
4868 || cmap_unmapped
4869 || md.as_ref().is_some_and(|m| detect_encoding_issues(m));
4870 (
4871 md,
4872 layout,
4873 enc,
4874 gid_encoded_pages,
4875 text_quality.pages_needing_ocr,
4876 ocr_reasons_by_page,
4877 font_cmap_gaps(cmap_coverage),
4878 )
4879 }
4880 None => (
4881 None,
4882 LayoutComplexity::default(),
4883 false,
4884 std::collections::HashSet::new(),
4885 Vec::new(),
4886 BTreeMap::new(),
4887 Vec::new(),
4888 ),
4889 };
4890
4891 let (pdf_type, markdown, confidence) =
4895 if pdf_type == PdfType::Mixed && markdown.as_ref().is_some_and(|m| is_garbage_text(m)) {
4896 (PdfType::Scanned, None, 0.95)
4897 } else {
4898 (pdf_type, markdown, confidence)
4899 };
4900
4901 let (markdown, has_encoding_issues, force_ocr_all) = if pdf_type == PdfType::TextBased
4905 && markdown.as_ref().is_some_and(|m| is_garbage_text(m))
4906 {
4907 log::debug!("TextBased PDF has garbage text — flagging all pages for OCR");
4908 (None, true, true)
4909 } else {
4910 (markdown, has_encoding_issues, false)
4911 };
4912
4913 let all_gid = !gid_pages.is_empty() && gid_pages.len() as u32 >= page_count;
4916 let mut pages_needing_ocr = pages_needing_ocr;
4917 if force_ocr_all {
4918 pages_needing_ocr = (1..=page_count).collect();
4919 }
4920 if !gid_pages.is_empty() {
4921 log::debug!("pages with gid-encoded fonts (need OCR): {:?}", gid_pages);
4922 for page in gid_pages {
4923 if !pages_needing_ocr.contains(&page) {
4924 pages_needing_ocr.push(page);
4925 }
4926 }
4927 pages_needing_ocr.sort_unstable();
4928 }
4929 if !text_quality_pages.is_empty() {
4930 log::debug!(
4931 "pages with OCR reason {} (need OCR): {:?}",
4932 OCR_REASON_SUSPECTED_GARBLED_TEXT,
4933 text_quality_pages
4934 );
4935 for page in text_quality_pages {
4936 if !pages_needing_ocr.contains(&page) {
4937 pages_needing_ocr.push(page);
4938 }
4939 }
4940 pages_needing_ocr.sort_unstable();
4941 }
4942
4943 if pdf_type == PdfType::TextBased
4948 && page_count > 0
4949 && pages_needing_ocr.is_empty()
4950 && markdown.is_some()
4951 {
4952 let md_len = markdown.as_ref().map_or(0, |m| m.len());
4953 let chars_per_page = md_len as f32 / page_count as f32;
4954 if chars_per_page < 50.0 && md_len < 500 {
4955 log::debug!(
4956 "sparse extraction: {:.0} chars/page — recommending OCR for all {} pages",
4957 chars_per_page,
4958 page_count
4959 );
4960 pages_needing_ocr = (1..=page_count).collect();
4961 }
4962 }
4963
4964 let markdown = if all_gid {
4965 log::debug!(
4966 "all {} pages have gid-encoded fonts — suppressing markdown output",
4967 page_count
4968 );
4969 None
4970 } else {
4971 markdown
4972 };
4973
4974 Ok(PdfProcessResult {
4975 pdf_type,
4976 markdown,
4977 page_count,
4978 processing_time_ms: start.elapsed_ms(),
4979 pages_needing_ocr,
4980 ocr_reasons_by_page: {
4981 let mut merged = detection_ocr_reasons;
4985 merge_ocr_reasons(&mut merged, text_quality_reasons_by_page);
4986 page_ocr_reasons_vec(merged)
4987 },
4988 title,
4989 author,
4990 subject,
4991 keywords,
4992 creator,
4993 producer,
4994 creation_date,
4995 mod_date,
4996 confidence,
4997 layout,
4998 has_encoding_issues,
4999 cmap_gaps,
5000 })
5001}
5002
5003fn font_cmap_gaps(coverage: types::CMapCoverageByFont) -> Vec<FontCMapGaps> {
5006 coverage
5007 .into_iter()
5008 .filter(|(_, stats)| stats.has_gaps())
5009 .map(|(font, stats)| FontCMapGaps {
5010 font,
5011 codes: stats.codes,
5012 interpolated: stats.interpolated,
5013 unmapped: stats.unmapped,
5014 })
5015 .collect()
5016}
5017
5018fn suspected_garbled_reason() -> String {
5023 OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()
5024}
5025
5026pub(crate) fn add_ocr_reason(
5027 reasons_by_page: &mut BTreeMap<u32, Vec<String>>,
5028 page: u32,
5029 reason: &str,
5030) {
5031 let reasons = reasons_by_page.entry(page).or_default();
5032 if !reasons.iter().any(|existing| existing == reason) {
5033 reasons.push(reason.to_string());
5034 }
5035}
5036
5037fn merge_ocr_reasons(
5038 reasons_by_page: &mut BTreeMap<u32, Vec<String>>,
5039 extra_reasons_by_page: BTreeMap<u32, Vec<String>>,
5040) {
5041 for (page, reasons) in extra_reasons_by_page {
5042 for reason in reasons {
5043 add_ocr_reason(reasons_by_page, page, &reason);
5044 }
5045 }
5046}
5047
5048fn page_ocr_reason(reasons_by_page: &BTreeMap<u32, Vec<String>>, page: u32) -> Option<String> {
5049 reasons_by_page
5050 .get(&page)
5051 .and_then(|reasons| reasons.first())
5052 .cloned()
5053}
5054
5055fn page_ocr_reasons_vec(reasons_by_page: BTreeMap<u32, Vec<String>>) -> Vec<PageOcrReasons> {
5056 reasons_by_page
5057 .into_iter()
5058 .map(|(page, reasons)| PageOcrReasons { page, reasons })
5059 .collect()
5060}
5061
5062fn captured_only_a_fragment(markdown: &str, region_text_chars: usize) -> bool {
5101 if region_text_chars <= 200 {
5102 return false;
5103 }
5104 let captured_text_chars: usize = markdown
5105 .chars()
5106 .filter(|c| !matches!(c, '|' | '-' | '\n'))
5107 .count();
5108 captured_text_chars * 4 < region_text_chars
5109}
5110
5111fn region_text_density_too_low(region_text_chars: usize, region_area: f32) -> bool {
5142 if region_text_chars < 20 {
5143 return false;
5144 }
5145 if !(30_000.0..=400_000.0).contains(®ion_area) {
5146 return false;
5147 }
5148 (region_text_chars as f32) / region_area < 0.003
5149}
5150
5151#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5152enum TableCandidateSource {
5153 Rect,
5154 Line,
5155 Heuristic,
5156 Column,
5157 KeyValue,
5158}
5159
5160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
5161enum TableCandidateIssue {
5162 LineRowUndercount,
5163 SparseWideUndercount,
5164 TextColumnUndercount,
5165 ProseGridFragment,
5166}
5167
5168#[derive(Debug, Clone)]
5169struct TableCandidate {
5170 markdown: String,
5171 source: TableCandidateSource,
5172 shape: MarkdownTableShape,
5173 issue: Option<TableCandidateIssue>,
5174}
5175
5176#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
5177struct MarkdownTableShape {
5178 rows: usize,
5179 cols: usize,
5180 raw_cols: usize,
5181}
5182
5183fn select_table_candidate(candidates: &[TableCandidate]) -> Option<&TableCandidate> {
5184 let first = candidates.first()?;
5185
5186 if first.issue == Some(TableCandidateIssue::LineRowUndercount) {
5191 return candidates.iter().find(|candidate| {
5192 matches!(
5193 candidate.source,
5194 TableCandidateSource::Heuristic
5195 | TableCandidateSource::Column
5196 | TableCandidateSource::KeyValue
5197 ) && candidate.issue.is_none()
5198 && candidate.shape.cols * 10 >= first.shape.cols * 13
5199 });
5200 }
5201
5202 let mut accepted = candidates
5203 .iter()
5204 .find(|candidate| candidate.issue.is_none())?;
5205
5206 if matches!(
5211 accepted.source,
5212 TableCandidateSource::Rect | TableCandidateSource::Line
5213 ) {
5214 if let Some(heuristic) = candidates.iter().find(|candidate| {
5215 matches!(
5216 candidate.source,
5217 TableCandidateSource::Heuristic
5218 | TableCandidateSource::Column
5219 | TableCandidateSource::KeyValue
5220 ) && candidate.issue.is_none()
5221 && heuristic_substantially_better(candidate.shape, accepted.shape)
5222 }) {
5223 accepted = heuristic;
5224 }
5225 }
5226
5227 if accepted.source == TableCandidateSource::Heuristic {
5228 if let Some(layout_candidate) = candidates.iter().find(|candidate| {
5229 matches!(
5230 candidate.source,
5231 TableCandidateSource::Column | TableCandidateSource::KeyValue
5232 ) && candidate.issue.is_none()
5233 && candidate.shape.cols >= accepted.shape.cols
5234 && candidate.shape.rows > accepted.shape.rows
5235 }) {
5236 accepted = layout_candidate;
5237 }
5238 }
5239
5240 Some(accepted)
5241}
5242
5243fn heuristic_substantially_better(
5244 heuristic: MarkdownTableShape,
5245 accepted: MarkdownTableShape,
5246) -> bool {
5247 (accepted.rows > 0 && heuristic.rows * 2 >= accepted.rows * 3)
5248 || (accepted.cols > 0 && heuristic.cols * 10 >= accepted.cols * 13)
5249}
5250
5251fn markdown_table_shape(markdown: &str) -> MarkdownTableShape {
5252 let mut shape = MarkdownTableShape::default();
5253 for cells in markdown_pipe_rows(markdown) {
5254 shape.rows += 1;
5255 shape.raw_cols = shape.raw_cols.max(cells.len());
5256 shape.cols = shape
5257 .cols
5258 .max(cells.iter().filter(|cell| !cell.trim().is_empty()).count());
5259 }
5260 shape
5261}
5262
5263fn markdown_pipe_rows(markdown: &str) -> Vec<Vec<&str>> {
5264 markdown
5265 .lines()
5266 .filter_map(|line| {
5267 let trimmed = line.trim();
5268 if !trimmed.starts_with('|') || !trimmed.ends_with('|') {
5269 return None;
5270 }
5271 if is_markdown_separator_row(trimmed) {
5272 return None;
5273 }
5274 let parts: Vec<&str> = trimmed.split('|').collect();
5275 if parts.len() < 3 {
5276 return None;
5277 }
5278 Some(parts[1..parts.len() - 1].to_vec())
5279 })
5280 .collect()
5281}
5282
5283fn is_markdown_separator_row(line: &str) -> bool {
5284 let mut saw_dash = false;
5285 for ch in line.chars() {
5286 match ch {
5287 '-' => saw_dash = true,
5288 '|' | ':' | ' ' => {}
5289 _ => return false,
5290 }
5291 }
5292 saw_dash
5293}
5294
5295fn line_table_collapses_text_rows(
5296 table: &tables::Table,
5297 items: &[TextItem],
5298 shape: MarkdownTableShape,
5299) -> bool {
5300 let table_rows = shape.rows.max(
5301 table
5302 .cells
5303 .iter()
5304 .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5305 .count(),
5306 );
5307 let table_cols = shape.raw_cols.max(shape.cols);
5308 if !(2..=4).contains(&table_rows) || table_cols < 3 {
5309 return false;
5310 }
5311
5312 let captured_items: Vec<&TextItem> = table
5313 .item_indices
5314 .iter()
5315 .filter_map(|&idx| items.get(idx))
5316 .filter(|item| !item.text.trim().is_empty())
5317 .collect();
5318 let captured_chars: usize = captured_items
5319 .iter()
5320 .map(|item| item.text.chars().count())
5321 .sum();
5322 if captured_chars <= 200 {
5323 return false;
5324 }
5325
5326 let implicit_rows = y_cluster_count(&captured_items);
5327 implicit_rows >= 4 && table_rows * 2 <= implicit_rows
5328}
5329
5330fn y_cluster_count(items: &[&TextItem]) -> usize {
5331 if items.is_empty() {
5332 return 0;
5333 }
5334 let mut ys: Vec<f32> = items.iter().map(|item| item.y).collect();
5335 ys.sort_by(|a, b| a.total_cmp(b));
5336
5337 let mut clusters = 1;
5338 let mut center = ys[0];
5339 let mut count = 1usize;
5340 for &y in &ys[1..] {
5341 if (y - center).abs() > 3.0 {
5342 clusters += 1;
5343 center = y;
5344 count = 1;
5345 } else {
5346 center = (center * count as f32 + y) / (count as f32 + 1.0);
5347 count += 1;
5348 }
5349 }
5350 clusters
5351}
5352
5353fn has_vertical_rules(lines: &[PdfLine]) -> bool {
5354 let angle_tolerance = 2.0_f32.to_radians().tan();
5355 lines
5356 .iter()
5357 .filter(|line| {
5358 let dx = (line.x2 - line.x1).abs();
5359 let dy = (line.y2 - line.y1).abs();
5360 let length = (dx * dx + dy * dy).sqrt();
5361 length >= 20.0 && dy > 0.01 && dx / dy <= angle_tolerance
5362 })
5363 .count()
5364 >= 2
5365}
5366
5367fn wide_table_sparse_prefix_undercount(markdown: &str) -> bool {
5368 let rows = markdown_pipe_rows(markdown);
5369 if rows.len() < 4 {
5370 return false;
5371 }
5372 let header = &rows[0];
5373 let raw_cols = header.len();
5374 if raw_cols < 8 {
5375 return false;
5376 }
5377
5378 let empty_headers: Vec<usize> = header
5379 .iter()
5380 .enumerate()
5381 .filter_map(|(idx, cell)| cell.trim().is_empty().then_some(idx))
5382 .collect();
5383 if empty_headers.len() != 1 {
5384 return false;
5385 }
5386 let empty_header_idx = empty_headers[0];
5387 if empty_header_idx == 0 || empty_header_idx >= raw_cols / 2 {
5388 return false;
5389 }
5390
5391 let prefix_end = (raw_cols / 2).max(empty_header_idx + 1).min(raw_cols);
5392 if prefix_end <= 2 {
5393 return false;
5394 }
5395
5396 let mut data_rows = 0usize;
5397 let mut sparse_prefix_rows = 0usize;
5398 for row in rows.iter().skip(1) {
5399 if row.iter().all(|cell| cell.trim().is_empty()) {
5400 continue;
5401 }
5402 data_rows += 1;
5403 let empty_prefix_cells = row
5404 .iter()
5405 .skip(1)
5406 .take(prefix_end.saturating_sub(1))
5407 .filter(|cell| cell.trim().is_empty())
5408 .count();
5409 if empty_prefix_cells >= 2 {
5410 sparse_prefix_rows += 1;
5411 }
5412 }
5413
5414 data_rows >= 3 && sparse_prefix_rows * 2 >= data_rows
5415}
5416
5417fn text_cluster_column_undercount(items: &[TextItem], shape: MarkdownTableShape) -> bool {
5418 let table_cols = shape.raw_cols.max(shape.cols);
5419 if table_cols < 2 || items.len() < table_cols * 2 {
5420 return false;
5421 }
5422
5423 let cluster_counts = x_cluster_item_counts(items);
5428 let Some(&dominant) = cluster_counts.iter().max() else {
5429 return false;
5430 };
5431 let min_cluster_size = (dominant / 4).max(2);
5432 let significant_clusters: usize = cluster_counts
5433 .iter()
5434 .filter(|&&n| n >= min_cluster_size)
5435 .count();
5436
5437 let wide_undercount = table_cols >= 6
5446 && significant_clusters >= table_cols + 2
5447 && significant_clusters * 10 >= table_cols * 12;
5448 let narrow_undercount = significant_clusters >= 3 && significant_clusters >= table_cols * 2;
5449 wide_undercount || narrow_undercount
5450}
5451
5452fn x_cluster_item_counts(items: &[TextItem]) -> Vec<usize> {
5457 let mut xs: Vec<f32> = items
5458 .iter()
5459 .filter(|item| !item.text.trim().is_empty())
5460 .map(|item| item.x)
5461 .collect();
5462 if xs.is_empty() {
5463 return Vec::new();
5464 }
5465 xs.sort_by(|a, b| a.total_cmp(b));
5466
5467 let mut counts: Vec<usize> = Vec::new();
5468 let mut center = xs[0];
5469 let mut count = 1usize;
5470 for &x in &xs[1..] {
5471 if (x - center).abs() > 8.0 {
5472 counts.push(count);
5473 center = x;
5474 count = 1;
5475 } else {
5476 center = (center * count as f32 + x) / (count as f32 + 1.0);
5477 count += 1;
5478 }
5479 }
5480 counts.push(count);
5481 counts
5482}
5483
5484fn prose_grid_fragment_needs_ocr(markdown: &str) -> bool {
5485 let rows = markdown_pipe_rows(markdown);
5486 if rows.len() < 2 {
5487 return false;
5488 }
5489 let raw_cols = rows[0].len();
5490 if !(2..=4).contains(&raw_cols) {
5491 return false;
5492 }
5493
5494 let mut seen_by_col = vec![0usize; raw_cols];
5495 let mut compact_by_col = vec![0usize; raw_cols];
5496 let mut long_prose = 0usize;
5497 let mut total = 0usize;
5498
5499 for row in rows.iter().skip(1) {
5500 for (col, cell) in row.iter().take(raw_cols).enumerate() {
5501 let trimmed = cell.trim();
5502 if trimmed.is_empty() {
5503 continue;
5504 }
5505 total += 1;
5506 seen_by_col[col] += 1;
5507 if compact_identifier_cell(trimmed) {
5508 compact_by_col[col] += 1;
5509 }
5510 if long_prose_cell(trimmed) {
5511 long_prose += 1;
5512 }
5513 }
5514 }
5515
5516 let min_total = if raw_cols == 2 { 2 } else { raw_cols * 2 };
5517 if total < min_total {
5518 return false;
5519 }
5520 if long_prose * 3 < total * 2 {
5521 return false;
5522 }
5523
5524 !seen_by_col
5525 .iter()
5526 .zip(compact_by_col.iter())
5527 .any(|(&seen, &compact)| seen >= 3 && compact * 2 >= seen)
5528}
5529
5530fn compact_identifier_cell(cell: &str) -> bool {
5531 let trimmed = cell.trim();
5532 if trimmed.is_empty() {
5533 return false;
5534 }
5535 let words = word_count(trimmed);
5536 if words <= 3 && trimmed.chars().count() <= 40 {
5537 return true;
5538 }
5539
5540 let chars = trimmed.chars().filter(|c| !c.is_whitespace()).count();
5541 if chars == 0 || chars > 48 {
5542 return false;
5543 }
5544 let compact_marks = trimmed
5545 .chars()
5546 .filter(|c| {
5547 c.is_ascii_digit()
5548 || matches!(c, '.' | ',' | ':' | ';' | '/' | '-' | '(' | ')' | '[' | ']')
5549 })
5550 .count();
5551 compact_marks * 2 >= chars
5552}
5553
5554fn long_prose_cell(cell: &str) -> bool {
5555 let trimmed = cell.trim();
5556 if compact_identifier_cell(trimmed) {
5557 return false;
5558 }
5559 let words = word_count(trimmed);
5560 let alpha = trimmed.chars().filter(|c| c.is_alphabetic()).count();
5561 words >= 4 && alpha >= 12
5562}
5563
5564fn word_count(text: &str) -> usize {
5565 text.split_whitespace()
5566 .filter(|word| word.chars().any(|c| c.is_alphanumeric()))
5567 .count()
5568}
5569
5570fn starts_with_numbered_table_label(cell: &str) -> bool {
5571 let trimmed = cell.trim_start();
5572 let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
5573
5574 digit_count > 0
5575 && digit_count <= 3
5576 && trimmed
5577 .chars()
5578 .nth(digit_count)
5579 .is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
5580}
5581
5582fn starts_with_uppercase_alpha(cell: &str) -> bool {
5583 cell.chars()
5584 .find(|c| c.is_alphabetic())
5585 .is_some_and(|c| c.is_uppercase())
5586}
5587
5588fn compact_title_like_cell(cell: &str) -> bool {
5589 let trimmed = cell.trim();
5590 if trimmed.len() < 3 || trimmed.len() > 80 || !starts_with_uppercase_alpha(trimmed) {
5591 return false;
5592 }
5593 let words = trimmed
5594 .split_whitespace()
5595 .filter(|word| word.chars().any(|c| c.is_alphabetic()))
5596 .count();
5597 (1..=6).contains(&words)
5598}
5599
5600fn numbered_rowspan_hierarchy_needs_ocr(markdown: &str) -> bool {
5601 let rows = markdown_pipe_rows(markdown);
5602 if rows.len() < 6 {
5603 return false;
5604 }
5605
5606 let n_cols = rows[0].len();
5607 if !(3..=6).contains(&n_cols) {
5608 return false;
5609 }
5610
5611 let data_rows = &rows[1..];
5612 let numbered_group_rows = data_rows
5613 .iter()
5614 .filter(|row| {
5615 row.first()
5616 .is_some_and(|cell| starts_with_numbered_table_label(cell))
5617 })
5618 .count();
5619 let blank_first_subrows = data_rows
5620 .iter()
5621 .filter(|row| {
5622 row.first().is_some_and(|cell| cell.trim().is_empty())
5623 && row.get(1).is_some_and(|cell| compact_title_like_cell(cell))
5624 })
5625 .count();
5626
5627 numbered_group_rows >= 2 && blank_first_subrows >= 2
5633}
5634
5635fn looks_like_partial_table_ex(markdown: &str, layout_assisted: bool) -> bool {
5636 let lines: Vec<&str> = markdown.lines().filter(|l| l.starts_with('|')).collect();
5637 if lines.len() < 2 {
5638 return false;
5639 }
5640 let header_line = lines[0];
5642 let separator_line = lines.get(1).copied().unwrap_or("");
5643 let is_separator = |l: &str| l.chars().all(|c| matches!(c, '|' | '-' | ' '));
5644 if !is_separator(separator_line) {
5645 return true;
5649 }
5650
5651 let cells: Vec<&str> = header_line.split('|').map(|s| s.trim()).collect::<Vec<_>>();
5653 if cells.len() < 3 {
5655 return false;
5656 }
5657 let header_cells: Vec<&str> = cells[1..cells.len() - 1].to_vec();
5658 let n_cols = header_cells.len();
5659 if n_cols < 2 {
5660 return false;
5664 }
5665
5666 if layout_assisted && numbered_rowspan_hierarchy_needs_ocr(markdown) {
5667 return true;
5668 }
5669
5670 if !layout_assisted {
5675 if let Some(first) = header_cells.first() {
5676 let trimmed = first.trim();
5677 if !trimmed.is_empty() && trimmed.chars().all(|c| c.is_ascii_digit()) {
5678 return true;
5679 }
5680 }
5681 }
5682
5683 let header_empty_indices: Vec<usize> = header_cells
5689 .iter()
5690 .enumerate()
5691 .filter_map(|(idx, cell)| cell.is_empty().then_some(idx))
5692 .collect();
5693 let empty_count = header_empty_indices.len();
5694 if layout_assisted {
5695 if n_cols >= 3
5696 && empty_count >= 2
5697 && !layout_assisted_empty_header_has_dense_body(markdown, n_cols)
5698 {
5699 return true;
5700 }
5701 } else if n_cols >= 3 && empty_count >= 1 {
5702 return true;
5703 }
5704
5705 let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
5707 for cell in &header_cells {
5708 if cell.is_empty() {
5709 continue;
5710 }
5711 if !seen.insert(cell) {
5712 return true;
5713 }
5714 }
5715
5716 if let Some(first_data_line) = lines.get(2) {
5722 let data_cells: Vec<&str> = first_data_line
5723 .split('|')
5724 .map(|s| s.trim())
5725 .collect::<Vec<_>>();
5726 if data_cells.len() >= 3 {
5727 let data_inner = &data_cells[1..data_cells.len() - 1];
5728 let empty_data = data_inner.iter().filter(|c| c.is_empty()).count();
5729 let threshold = if layout_assisted { 2 } else { 3 };
5735 if n_cols >= 3 && empty_data * threshold >= n_cols {
5736 let sparse_row_shares_header_spacer = layout_assisted
5737 && data_inner.iter().enumerate().any(|(idx, cell)| {
5738 cell.trim().is_empty() && header_empty_indices.contains(&idx)
5739 })
5740 && layout_assisted_empty_header_has_dense_body(markdown, n_cols);
5741 let sparse_row_is_section_label = layout_assisted
5742 && layout_assisted_sparse_section_row_is_ok(data_inner, markdown, n_cols);
5743 if !sparse_row_shares_header_spacer && !sparse_row_is_section_label {
5744 return true;
5745 }
5746 }
5747 }
5748 }
5749
5750 let data_rows: Vec<Vec<&str>> = lines
5760 .iter()
5761 .skip(2) .map(|l| {
5763 let parts: Vec<&str> = l.split('|').map(|s| s.trim()).collect();
5764 if parts.len() >= 3 {
5765 parts[1..parts.len() - 1].to_vec()
5766 } else {
5767 Vec::new()
5768 }
5769 })
5770 .filter(|cells| !cells.is_empty())
5771 .collect();
5772
5773 if n_cols >= 2 && data_rows.len() >= 4 {
5774 let mut continuation = 0;
5775 let mut total = 0;
5776 for row in &data_rows {
5777 for cell in row {
5778 let trimmed = cell.trim();
5779 if trimmed.is_empty() {
5780 continue;
5781 }
5782 total += 1;
5783 let first = trimmed.chars().next().unwrap();
5784 if first.is_lowercase()
5787 || matches!(first, ',' | '.' | ';' | ')' | '"' | '\'' | '”' | '’')
5788 {
5789 continuation += 1;
5790 }
5791 }
5792 }
5793 if total > 0 && continuation * 5 >= total * 3 {
5794 return true;
5797 }
5798 }
5799
5800 false
5801}
5802
5803fn layout_assisted_empty_header_has_dense_body(markdown: &str, n_cols: usize) -> bool {
5804 let rows = markdown_pipe_rows(markdown);
5805 let data_rows: Vec<&Vec<&str>> = rows
5806 .iter()
5807 .skip(1)
5808 .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5809 .collect();
5810 if data_rows.len() < 2 || n_cols < 3 {
5811 return false;
5812 }
5813
5814 let total_cells = data_rows.len() * n_cols;
5815 let mut filled_cells = 0usize;
5816 let mut rows_with_multiple_cells = 0usize;
5817 let mut max_filled_in_row = 0usize;
5818 for row in &data_rows {
5819 let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
5820 filled_cells += filled;
5821 max_filled_in_row = max_filled_in_row.max(filled);
5822 if filled >= 2 {
5823 rows_with_multiple_cells += 1;
5824 }
5825 }
5826
5827 rows_with_multiple_cells * 2 >= data_rows.len()
5831 && max_filled_in_row >= n_cols.min(3)
5832 && filled_cells * 100 >= total_cells * 45
5833}
5834
5835fn layout_assisted_sparse_section_row_is_ok(row: &[&str], markdown: &str, n_cols: usize) -> bool {
5836 let labels: Vec<&str> = row
5837 .iter()
5838 .map(|cell| cell.trim())
5839 .filter(|cell| !cell.is_empty())
5840 .collect();
5841 if labels.len() != 1 {
5842 return false;
5843 }
5844 let label = labels[0];
5845 if label.len() > 40 || !label.chars().any(|ch| ch.is_alphabetic()) {
5846 return false;
5847 }
5848 if label.ends_with('.') || label.ends_with('!') || label.ends_with('?') || label.ends_with(':')
5849 {
5850 return false;
5851 }
5852 layout_assisted_empty_header_has_dense_body(markdown, n_cols)
5853}
5854
5855fn markdown_table_body_is_dense(markdown: &str) -> bool {
5856 let rows = markdown_pipe_rows(markdown);
5857 let data_rows: Vec<&Vec<&str>> = rows
5858 .iter()
5859 .skip(1)
5860 .filter(|row| row.iter().any(|cell| !cell.trim().is_empty()))
5861 .collect();
5862 if data_rows.len() < 3 {
5863 return false;
5864 }
5865
5866 let cols = rows.iter().map(|row| row.len()).max().unwrap_or_default();
5867 if cols < 3 {
5868 return false;
5869 }
5870
5871 let mut filled_cells = 0usize;
5872 let mut rows_with_multiple_cells = 0usize;
5873 for row in &data_rows {
5874 let filled = row.iter().filter(|cell| !cell.trim().is_empty()).count();
5875 filled_cells += filled;
5876 if filled >= cols.min(3) {
5877 rows_with_multiple_cells += 1;
5878 }
5879 }
5880
5881 let total_cells = data_rows.len() * cols;
5882 rows_with_multiple_cells * 2 >= data_rows.len() && filled_cells * 100 >= total_cells * 45
5883}
5884
5885#[cfg(test)]
5888fn looks_like_partial_table(markdown: &str) -> bool {
5889 looks_like_partial_table_ex(markdown, false)
5890}
5891
5892#[cfg(test)]
5893mod text_cluster_column_undercount_tests {
5894 use super::{text_cluster_column_undercount, MarkdownTableShape, TextItem};
5895 use crate::types::ItemType;
5896
5897 fn item(x: f32, y: f32, text: &str) -> TextItem {
5898 TextItem {
5899 text: text.to_string(),
5900 x,
5901 y,
5902 width: text.len() as f32 * 5.0,
5903 height: 10.0,
5904 font: "F".into(),
5905 font_tag: String::new(),
5906 legacy_symbol_rewrite: false,
5907 font_size: 10.0,
5908 page: 1,
5909 is_bold: false,
5910 is_italic: false,
5911 font_weight: None,
5912 bold_source: None,
5913 fixed_pitch: None,
5914 fill_color: None,
5915 stroke_color: None,
5916 render_mode: None,
5917 is_underline: false,
5918 is_strikeout: false,
5919 rotation: 0.0,
5920 advance_known: true,
5921 item_type: ItemType::Text,
5922 mcid: None,
5923 baseline_shift: 0.0,
5924 }
5925 }
5926
5927 fn make_four_column_grid() -> Vec<TextItem> {
5931 let xs = [60.0, 145.0, 330.0, 430.0];
5932 let mut items = Vec::new();
5933 for row in 0..22 {
5934 let y = 700.0 - row as f32 * 20.0;
5935 for &x in &xs {
5936 items.push(item(x, y, "x"));
5937 }
5938 }
5939 items
5940 }
5941
5942 fn shape(cols: usize) -> MarkdownTableShape {
5943 MarkdownTableShape {
5944 rows: 22,
5945 cols,
5946 raw_cols: cols,
5947 }
5948 }
5949
5950 #[test]
5951 fn narrow_undercount_2col_markdown_4col_geometry_fires() {
5952 let items = make_four_column_grid();
5957 assert!(text_cluster_column_undercount(&items, shape(2)));
5958 }
5959
5960 #[test]
5961 fn matching_geometry_does_not_fire() {
5962 let items = make_four_column_grid();
5964 assert!(!text_cluster_column_undercount(&items, shape(4)));
5965 }
5966
5967 #[test]
5968 fn single_column_skipped() {
5969 let items = make_four_column_grid();
5971 assert!(!text_cluster_column_undercount(&items, shape(1)));
5972 }
5973
5974 #[test]
5975 fn insufficient_items_skipped() {
5976 let items = vec![
5978 item(60.0, 700.0, "x"),
5979 item(145.0, 700.0, "x"),
5980 item(330.0, 700.0, "x"),
5981 ];
5982 assert!(!text_cluster_column_undercount(&items, shape(2)));
5983 }
5984
5985 #[test]
5986 fn outlier_singletons_filtered_out() {
5987 let mut items = Vec::new();
5991 for row in 0..22 {
5992 let y = 700.0 - row as f32 * 20.0;
5993 items.push(item(60.0, y, "x"));
5994 items.push(item(330.0, y, "x"));
5995 }
5996 items.push(item(145.0, 100.0, "footnote"));
5998 items.push(item(430.0, 50.0, "page#"));
5999 assert!(!text_cluster_column_undercount(&items, shape(2)));
6000 }
6001
6002 #[test]
6003 fn wide_table_path_still_fires() {
6004 let mut items = Vec::new();
6008 let xs: Vec<f32> = (0..12).map(|i| 50.0 + i as f32 * 40.0).collect();
6009 for row in 0..22 {
6010 let y = 700.0 - row as f32 * 20.0;
6011 for &x in &xs {
6012 items.push(item(x, y, "x"));
6013 }
6014 }
6015 assert!(text_cluster_column_undercount(&items, shape(8)));
6016 }
6017}
6018
6019#[cfg(test)]
6020mod region_text_density_tests {
6021 use super::region_text_density_too_low;
6022
6023 #[test]
6024 fn small_region_skips_check() {
6025 assert!(!region_text_density_too_low(5, 10_000.0));
6029 }
6030
6031 #[test]
6032 fn dense_full_table_passes() {
6033 assert!(!region_text_density_too_low(6_500, 438_000.0));
6036 }
6037
6038 #[test]
6039 fn moderate_density_table_passes() {
6040 assert!(!region_text_density_too_low(1_600, 291_000.0));
6043 }
6044
6045 #[test]
6046 fn font_decode_failure_caught() {
6047 assert!(region_text_density_too_low(46, 102_000.0));
6050 }
6051
6052 #[test]
6053 fn sparse_glyph_repeat_caught() {
6054 assert!(region_text_density_too_low(89, 353_000.0));
6058 }
6059
6060 #[test]
6061 fn sparse_layout_band_caught() {
6062 assert!(region_text_density_too_low(96, 73_000.0));
6066 }
6067
6068 #[test]
6069 fn boundary_at_density_floor() {
6070 assert!(!region_text_density_too_low(90, 30_000.0));
6074 assert!(region_text_density_too_low(89, 30_000.0));
6076 }
6077
6078 #[test]
6079 fn whole_page_bbox_skips_check() {
6080 assert!(!region_text_density_too_low(423, 484_704.0));
6087 }
6088
6089 #[test]
6090 fn tiny_text_chars_skips_check() {
6091 assert!(!region_text_density_too_low(8, 127_800.0));
6097 }
6098}
6099
6100#[cfg(test)]
6101mod captured_only_a_fragment_tests {
6102 use super::captured_only_a_fragment;
6103
6104 #[test]
6105 fn small_region_skips_check() {
6106 let md = "|Year|Value|\n|---|---|\n|2024|10|";
6109 assert!(!captured_only_a_fragment(md, 50));
6110 }
6111
6112 #[test]
6113 fn full_table_passes() {
6114 let md =
6116 "|Name|Year|Country|\n|---|---|---|\n|Alice|2020|US|\n|Bob|2021|UK|\n|Carol|2019|FR|";
6117 assert!(!captured_only_a_fragment(md, 50));
6119 assert!(!captured_only_a_fragment(md, md.len()));
6121 }
6122
6123 #[test]
6124 fn header_only_extraction_rejected() {
6125 let md = "|Description|Year|Amount|\n|---|---|---|";
6128 assert!(captured_only_a_fragment(md, 1500));
6129 }
6130
6131 #[test]
6132 fn sparse_fragment_rejected() {
6133 let md = "|percent|for|\n|---|---|\n|sites|15|";
6135 assert!(captured_only_a_fragment(md, 2000));
6136 }
6137
6138 #[test]
6139 fn boundary_at_25_percent_floor() {
6140 let md = "x".repeat(250);
6144 assert!(!captured_only_a_fragment(&md, 1000));
6145 let md_under = "x".repeat(249);
6147 assert!(captured_only_a_fragment(&md_under, 1000));
6148 }
6149}
6150
6151#[cfg(test)]
6152mod table_candidate_selection_tests {
6153 use super::{
6154 line_table_collapses_text_rows, markdown_table_shape, prose_grid_fragment_needs_ocr,
6155 select_table_candidate, text_cluster_column_undercount,
6156 wide_table_sparse_prefix_undercount, MarkdownTableShape, TableCandidate,
6157 TableCandidateIssue, TableCandidateSource,
6158 };
6159 use crate::tables::Table;
6160 use crate::types::{ItemType, TextItem};
6161
6162 fn candidate(
6163 source: TableCandidateSource,
6164 rows: usize,
6165 cols: usize,
6166 issue: Option<TableCandidateIssue>,
6167 ) -> TableCandidate {
6168 TableCandidate {
6169 markdown: format!("{source:?}-{rows}x{cols}"),
6170 source,
6171 shape: MarkdownTableShape {
6172 rows,
6173 cols,
6174 raw_cols: cols,
6175 },
6176 issue,
6177 }
6178 }
6179
6180 fn item(text: &str, y: f32) -> TextItem {
6181 item_at(text, 10.0, y)
6182 }
6183
6184 fn item_at(text: &str, x: f32, y: f32) -> TextItem {
6185 TextItem {
6186 text: text.to_string(),
6187 x,
6188 y,
6189 width: 50.0,
6190 height: 10.0,
6191 font: "F1".to_string(),
6192 font_tag: String::new(),
6193 legacy_symbol_rewrite: false,
6194 font_size: 10.0,
6195 page: 1,
6196 is_bold: false,
6197 is_italic: false,
6198 font_weight: None,
6199 bold_source: None,
6200 fixed_pitch: None,
6201 fill_color: None,
6202 stroke_color: None,
6203 render_mode: None,
6204 is_underline: false,
6205 is_strikeout: false,
6206 rotation: 0.0,
6207 advance_known: true,
6208 item_type: ItemType::Text,
6209 mcid: None,
6210 baseline_shift: 0.0,
6211 }
6212 }
6213
6214 #[test]
6215 fn markdown_shape_counts_rows_and_non_empty_columns() {
6216 let md = "|A|B||D|\n|---|---|---|---|\n|one|two|three||";
6217 let shape = markdown_table_shape(md);
6218 assert_eq!(shape.rows, 2);
6219 assert_eq!(shape.cols, 3);
6220 assert_eq!(shape.raw_cols, 4);
6221 }
6222
6223 #[test]
6224 fn prefers_clean_heuristic_when_substantially_wider_than_vector() {
6225 let candidates = vec![
6226 candidate(TableCandidateSource::Rect, 5, 3, None),
6227 candidate(TableCandidateSource::Heuristic, 5, 4, None),
6228 ];
6229 let selected = select_table_candidate(&candidates).unwrap();
6230 assert_eq!(selected.source, TableCandidateSource::Heuristic);
6231 }
6232
6233 #[test]
6234 fn line_row_undercount_routes_to_ocr_without_wider_heuristic() {
6235 let candidates = vec![
6236 candidate(
6237 TableCandidateSource::Line,
6238 2,
6239 3,
6240 Some(TableCandidateIssue::LineRowUndercount),
6241 ),
6242 candidate(TableCandidateSource::Heuristic, 4, 3, None),
6243 ];
6244 assert!(select_table_candidate(&candidates).is_none());
6245 }
6246
6247 #[test]
6248 fn line_row_undercount_can_use_clearly_wider_heuristic() {
6249 let candidates = vec![
6250 candidate(
6251 TableCandidateSource::Line,
6252 2,
6253 3,
6254 Some(TableCandidateIssue::LineRowUndercount),
6255 ),
6256 candidate(TableCandidateSource::Heuristic, 2, 4, None),
6257 ];
6258 let selected = select_table_candidate(&candidates).unwrap();
6259 assert_eq!(selected.source, TableCandidateSource::Heuristic);
6260 }
6261
6262 #[test]
6263 fn prefers_clean_column_fallback_when_it_recovers_more_rows() {
6264 let candidates = vec![
6265 candidate(TableCandidateSource::Heuristic, 6, 5, None),
6266 candidate(TableCandidateSource::Column, 7, 5, None),
6267 ];
6268 let selected = select_table_candidate(&candidates).unwrap();
6269 assert_eq!(selected.source, TableCandidateSource::Column);
6270 }
6271
6272 #[test]
6273 fn line_candidate_collapsing_captured_y_clusters_is_suspicious() {
6274 let long = "value value value value value value value value value value value value";
6275 let items = vec![
6276 item(long, 100.0),
6277 item(long, 112.0),
6278 item(long, 124.0),
6279 item(long, 136.0),
6280 ];
6281 let table = Table::new(
6282 vec![0.0, 100.0, 200.0, 300.0],
6283 vec![150.0, 120.0],
6284 vec![
6285 vec!["A".to_string(), "B".to_string(), "C".to_string()],
6286 vec!["D".to_string(), "E".to_string(), "F".to_string()],
6287 ],
6288 vec![0, 1, 2, 3],
6289 );
6290 let shape = MarkdownTableShape {
6291 rows: 2,
6292 cols: 3,
6293 raw_cols: 3,
6294 };
6295 assert!(line_table_collapses_text_rows(&table, &items, shape));
6296 }
6297
6298 #[test]
6299 fn wide_sparse_prefix_with_blank_header_is_suspicious() {
6300 let md = "|Name|Flag A|Flag B||Metric A|Metric B|Metric C|Metric D|\n\
6301 |---|---|---|---|---|---|---|---|\n\
6302 |Row 1|Y|||1|2|3|4|\n\
6303 |Row 2|||N|5|6|7|8|\n\
6304 |Row 3||||9|10|11|12|";
6305 assert!(wide_table_sparse_prefix_undercount(md));
6306 }
6307
6308 #[test]
6309 fn wide_blank_header_with_dense_body_is_allowed() {
6310 let md = "|Name|Flag A|Flag B||Metric A|Metric B|Metric C|Metric D|\n\
6311 |---|---|---|---|---|---|---|---|\n\
6312 |Row 1|Y|N|Y|1|2|3|4|\n\
6313 |Row 2|N|Y|N|5|6|7|8|\n\
6314 |Row 3|Y|Y|N|9|10|11|12|";
6315 assert!(!wide_table_sparse_prefix_undercount(md));
6316 }
6317
6318 #[test]
6319 fn x_clusters_can_signal_wide_column_undercount() {
6320 let mut items = Vec::new();
6321 for y in [100.0, 112.0, 124.0] {
6322 for x in [
6323 10.0, 45.0, 80.0, 115.0, 150.0, 185.0, 220.0, 255.0, 290.0, 325.0,
6324 ] {
6325 items.push(item_at("value", x, y));
6326 }
6327 }
6328 let shape = MarkdownTableShape {
6329 rows: 4,
6330 cols: 8,
6331 raw_cols: 8,
6332 };
6333 assert!(text_cluster_column_undercount(&items, shape));
6334 }
6335
6336 #[test]
6337 fn wrapped_prose_grid_fragment_is_suspicious() {
6338 let md = "|A useful capability with several words|Another descriptive capability column|A final descriptive capability column|\n\
6339 |---|---|---|\n\
6340 |The group includes experienced specialists|received a strong neutral recommendation|system in a neutral evaluation setting|\n\
6341 |presented several papers in public venues|shown stronger performance than alternatives|ranked highly in a neutral benchmark|\n\
6342 |recognized by external reviewers|delivered useful results for operators|used in production style workflows|";
6343 assert!(prose_grid_fragment_needs_ocr(md));
6344 }
6345
6346 #[test]
6347 fn compact_identifier_columns_are_not_prose_fragments() {
6348 let md = "|Box 1, F-7|Description|Date|\n\
6349 |---|---|---|\n\
6350 |Box 1, F-8|Long neutral description with several words for one record|2020 Jan 1|\n\
6351 |Box 1, F-9|Another neutral description with several words for another record|2021 Feb 2|\n\
6352 |Box 1, F-10|Additional neutral description with several words for a record|n.d.|";
6353 assert!(!prose_grid_fragment_needs_ocr(md));
6354 }
6355
6356 #[test]
6357 fn two_column_all_prose_fragment_is_suspicious() {
6358 let md = "|A long descriptive header fragment|Another long descriptive header fragment|\n\
6359 |---|---|\n\
6360 |Long wrapped prose content from one visual cell|More wrapped prose content from a neighboring visual cell|";
6361 assert!(prose_grid_fragment_needs_ocr(md));
6362 }
6363
6364 #[test]
6365 fn two_column_key_value_table_is_allowed() {
6366 let md = "|Field|Detail|\n\
6367 |---|---|\n\
6368 |Status|Long neutral explanation with several words for this value|\n\
6369 |Owner|Another neutral explanation with several words for this value|";
6370 assert!(!prose_grid_fragment_needs_ocr(md));
6371 }
6372}
6373
6374#[cfg(test)]
6375mod looks_like_partial_table_tests {
6376 use super::{
6377 looks_like_partial_table, looks_like_partial_table_ex, markdown_table_body_is_dense,
6378 };
6379
6380 #[test]
6381 fn good_table_passes() {
6382 let md = "|Name|Year|Country|\n|---|---|---|\n|Alice|2020|US|\n|Bob|2021|UK|";
6383 assert!(
6384 !looks_like_partial_table(md),
6385 "should not flag well-formed table"
6386 );
6387 }
6388
6389 #[test]
6390 fn header_starting_with_number_is_partial() {
6391 let md = "|2|Cambodian Women for Peace|9,835|\n|---|---|---|\n|3|Association|711|";
6393 assert!(looks_like_partial_table(md));
6394 }
6395
6396 #[test]
6397 fn header_with_empty_cells_in_3col_is_partial() {
6398 let md =
6400 "|Position||Administration|Administration|\n|---|---|---|---|\n|Senate|24|8.3|16.7|";
6401 assert!(looks_like_partial_table(md));
6402 }
6403
6404 #[test]
6405 fn header_with_duplicate_cells_is_partial() {
6406 let md =
6408 "|Position|Administration|Administration|Notes|\n|---|---|---|---|\n|Senate|24|16|x|";
6409 assert!(looks_like_partial_table(md));
6410 }
6411
6412 #[test]
6413 fn two_column_with_one_empty_cell_passes() {
6414 let md = "|Key||\n|---|---|\n|Alice|123|\n|Bob|456|";
6416 assert!(!looks_like_partial_table(md));
6418 }
6419
6420 #[test]
6421 fn single_column_table_is_kept() {
6422 let md = "|Item|\n|---|\n|First|\n|Second|";
6425 assert!(!looks_like_partial_table(md));
6426 }
6427
6428 #[test]
6429 fn no_table_at_all_returns_true() {
6430 let md = "Just some text\nWith multiple lines";
6433 assert!(!looks_like_partial_table(md));
6435 }
6436
6437 #[test]
6438 fn first_data_row_with_many_empty_cells_is_partial() {
6439 let md = "|Government|No. of Seats|Aquino|Ramos|\n|---|---|---|---|\n|Position|||(1986-1992)|\n|Senate|24|8.3|16.7|";
6442 assert!(looks_like_partial_table(md));
6443 }
6444
6445 #[test]
6446 fn first_data_row_with_one_empty_cell_in_4col_passes() {
6447 let md = "|A|B|C|D|\n|---|---|---|---|\n|x|y||z|\n|p|q|r|s|";
6450 assert!(!looks_like_partial_table(md));
6451 }
6452
6453 #[test]
6454 fn paragraph_misread_as_two_column_table_is_partial() {
6455 let md = "|Approval is needed from the|Acquisitions of|\n\
6458 |---|---|\n\
6459 |Treasurer if the acquisition|residential and|\n\
6460 |constitutes a \"significant|agricultural|\n\
6461 |action,\" including acquiring an|land by foreign|\n\
6462 |interest in different types of|persons must be|\n\
6463 |land where the monetary|reported to the|";
6464 assert!(looks_like_partial_table(md));
6465 }
6466
6467 #[test]
6468 fn real_multi_word_table_is_kept() {
6469 let md = "|Country|Capital|Notes|\n\
6472 |---|---|---|\n\
6473 |United States|Washington DC|Federal capital|\n\
6474 |United Kingdom|London|City of London is a separate|\n\
6475 |France|Paris|Île-de-France region|\n\
6476 |Germany|Berlin|Reunified 1990|\n\
6477 |Spain|Madrid|Largest city in Spain|";
6478 assert!(!looks_like_partial_table(md));
6479 }
6480
6481 #[test]
6484 fn numeric_header_accepted_when_layout_assisted() {
6485 let md = "|2024|Revenue|Growth|\n|---|---|---|\n|Q1|1.2M|5%|\n|Q2|1.4M|8%|";
6487 assert!(
6488 looks_like_partial_table(md),
6489 "strict mode rejects numeric header"
6490 );
6491 assert!(
6492 !looks_like_partial_table_ex(md, true),
6493 "layout-assisted should accept"
6494 );
6495 }
6496
6497 #[test]
6498 fn one_empty_header_accepted_when_layout_assisted() {
6499 let md = "|Position||Senate|House|\n|---|---|---|---|\n|Chair|1|2|3|\n|Vice|4|5|6|";
6501 assert!(
6502 looks_like_partial_table(md),
6503 "strict rejects 1 empty header"
6504 );
6505 assert!(
6506 !looks_like_partial_table_ex(md, true),
6507 "layout-assisted allows 1 empty"
6508 );
6509 }
6510
6511 #[test]
6512 fn two_empty_headers_still_rejected_when_layout_assisted() {
6513 let md = "|A|||D|\n|---|---|---|---|\n|x|y|z|w|";
6515 assert!(
6516 looks_like_partial_table_ex(md, true),
6517 "2 empty headers with only one body row are rejected even layout-assisted"
6518 );
6519 }
6520
6521 #[test]
6522 fn dense_body_with_empty_merged_header_passes_when_layout_assisted() {
6523 let md = "|Year||Unadjusted Basis|||\n\
6524 |---|---|---|---|---|\n\
6525 |1|.1667|$100,000|$16,670|$16,670|\n\
6526 |2|.3333|$100,000|$33,330|$50,000|\n\
6527 |3|.3333|$100,000|$33,330|$88,330|\n\
6528 |4|.1667|$100,000|$16,670|$100,000|";
6529 assert!(
6530 looks_like_partial_table(md),
6531 "strict mode still rejects merged-header gaps"
6532 );
6533 assert!(
6534 !looks_like_partial_table_ex(md, true),
6535 "layout-assisted should trust a dense body under a merged header"
6536 );
6537 }
6538
6539 #[test]
6540 fn sparse_first_row_relaxed_when_layout_assisted() {
6541 let md = "|A|B|C|D|\n|---|---|---|---|\n|x||y|z|\n|p|q|r|s|";
6543 assert!(!looks_like_partial_table(md), "strict: 25% empty is OK");
6544 let md2 = "|A|B|C|D|\n|---|---|---|---|\n|||y|z|\n|p|q|r|s|";
6546 assert!(looks_like_partial_table(md2), "strict: 50% empty flagged");
6547 assert!(
6548 looks_like_partial_table_ex(md2, true),
6549 "layout-assisted: 50% also flagged"
6550 );
6551 let md3 = "|A|B|C|D|E|F|\n|---|---|---|---|---|---|\n|x|||y|z|w|\n|a|b|c|d|e|f|";
6553 assert!(looks_like_partial_table(md3), "strict: 33% flagged");
6554 assert!(
6555 !looks_like_partial_table_ex(md3, true),
6556 "layout-assisted: 33% accepted"
6557 );
6558 }
6559
6560 #[test]
6561 fn sparse_first_row_with_header_spacer_passes_when_layout_assisted() {
6562 let md = "|Properties|Instruction||Training Datasets Alignment|\n\
6563 |---|---|---|---|\n\
6564 ||Alpaca-GPT4 OpenOrca Synth. Math-Instruct||Orca DPO Pairs Ultrafeedback Cleaned|\n\
6565 |Total # Samples|52K 2.91M 126K||12.9K 60.8K 126K|";
6566 assert!(
6567 looks_like_partial_table(md),
6568 "strict mode rejects the sparse first row"
6569 );
6570 assert!(
6571 !looks_like_partial_table_ex(md, true),
6572 "layout-assisted should allow sparse rows that share a header spacer column"
6573 );
6574 }
6575
6576 #[test]
6577 fn sparse_section_row_passes_when_layout_assisted_body_is_dense() {
6578 let md = "|Properties|Conditions|Method|Typical values|Units|\n\
6579 |---|---|---|---|---|\n\
6580 |Rheology|||||\n\
6581 |Melt Flow Rate|230 C/2.16 kg|ASTM D1238|3.0|g/10 min|\n\
6582 |Tensile Stress at Yield|50 mm/min|ASTM D638|31|MPa|\n\
6583 |Elongation at Yield|50 mm/min|ASTM D638|8|%|";
6584 assert!(
6585 looks_like_partial_table(md),
6586 "strict mode rejects the sparse first row"
6587 );
6588 assert!(
6589 !looks_like_partial_table_ex(md, true),
6590 "layout-assisted should allow a short section label above dense table rows"
6591 );
6592 }
6593
6594 #[test]
6595 fn paragraph_still_rejected_when_layout_assisted() {
6596 let md = "|Approval is needed from the|Acquisitions of|\n\
6598 |---|---|\n\
6599 |Treasurer if the acquisition|residential and|\n\
6600 |constitutes a \"significant|agricultural|\n\
6601 |action,\" including acquiring an|land by foreign|\n\
6602 |interest in different types of|persons must be|\n\
6603 |land where the monetary|reported to the|";
6604 assert!(
6605 looks_like_partial_table_ex(md, true),
6606 "paragraph rejection stays strict"
6607 );
6608 }
6609
6610 #[test]
6611 fn numbered_rowspan_hierarchy_rejected_when_layout_assisted() {
6612 let md = "|Group|Task|Detail|Benefit|\n\
6613 |---|---|---|---|\n\
6614 |1. Group alpha|Task setup|Begin setup|Faster start|\n\
6615 |2. Group beta|Storage setup|Provides tools||\n\
6616 ||Label workspace|Creates review sets|Lets teams review|\n\
6617 ||Model training|Builds model|Supports rollout|\n\
6618 |3. Group gamma|Pipeline setup|Configures flow|Improves control|";
6619 assert!(
6620 looks_like_partial_table_ex(md, true),
6621 "layout-assisted native extraction should fall back for numbered row-spanned hierarchies"
6622 );
6623 }
6624
6625 #[test]
6626 fn plain_numbered_table_kept_when_layout_assisted() {
6627 let md = "|Step|Task|Detail|Benefit|\n\
6628 |---|---|---|---|\n\
6629 |1. Group alpha|Task setup|Begin setup|Faster start|\n\
6630 |2. Group beta|Storage setup|Provides tools|Easier review|\n\
6631 |3. Group gamma|Pipeline setup|Configures flow|Improves control|";
6632 assert!(
6633 !looks_like_partial_table_ex(md, true),
6634 "numbered rows alone are fine; the guard requires blank-first-column sub-rows"
6635 );
6636 }
6637
6638 #[test]
6639 fn duplicate_headers_still_rejected_when_layout_assisted() {
6640 let md =
6641 "|Position|Administration|Administration|Notes|\n|---|---|---|---|\n|Senate|24|16|x|";
6642 assert!(
6643 looks_like_partial_table_ex(md, true),
6644 "duplicate headers rejected even layout-assisted"
6645 );
6646 }
6647
6648 #[test]
6649 fn dense_numeric_table_body_is_structurally_trusted() {
6650 let md = "|Year|3-Year|5-Year|7-Year|\n\
6651 |---|---|---|---|\n\
6652 |1|33.0%|20.00%|14.29%|\n\
6653 |2|44.45%|32.00%|24.49%|\n\
6654 |3|14.81%|19.20%|17.49%|\n\
6655 |4|7.41%|11.52%|12.49%|";
6656 assert!(markdown_table_body_is_dense(md));
6657 }
6658
6659 #[test]
6660 fn sparse_markdown_fragment_is_not_structurally_trusted() {
6661 let md = "|A|B|C|D|\n\
6662 |---|---|---|---|\n\
6663 |x||||\n\
6664 |||y||\n\
6665 ||||z|";
6666 assert!(!markdown_table_body_is_dense(md));
6667 }
6668}
6669
6670struct FolioFilteredItems {
6671 items: Vec<types::TextItem>,
6672 layout_items: Vec<types::TextItem>,
6673 removal_mask: Vec<bool>,
6674 removed_pages: HashSet<u32>,
6675}
6676
6677fn select_items_with_document_folio_context(
6680 all_items: Vec<types::TextItem>,
6681 page_count: u32,
6682 page_filter: Option<&HashSet<u32>>,
6683) -> FolioFilteredItems {
6684 let (all_layout_items, all_removed_pages, all_removal_mask) =
6685 extractor::filter_markdown_page_numbers_with_removed_pages(all_items.clone(), page_count);
6686 let selected_page = |page: u32| page_filter.is_none_or(|filter| filter.contains(&page));
6687
6688 let (items, removal_mask) = all_items
6689 .into_iter()
6690 .zip(all_removal_mask)
6691 .filter(|(item, _)| selected_page(item.page))
6692 .unzip();
6693 let layout_items = all_layout_items
6694 .into_iter()
6695 .filter(|item| selected_page(item.page))
6696 .collect();
6697 let removed_pages = all_removed_pages
6698 .into_iter()
6699 .filter(|page| selected_page(*page))
6700 .collect();
6701
6702 FolioFilteredItems {
6703 items,
6704 layout_items,
6705 removal_mask,
6706 removed_pages,
6707 }
6708}
6709
6710#[cfg(test)]
6712fn compute_layout_complexity(
6713 items: &[types::TextItem],
6714 column_items: &[types::TextItem],
6715 rects: &[types::PdfRect],
6716 lines: &[types::PdfLine],
6717) -> LayoutComplexity {
6718 let page_chart_regions = markdown::chart_regions_by_page(items, rects, lines);
6719 compute_layout_complexity_with_chart_regions(
6720 items,
6721 column_items,
6722 rects,
6723 lines,
6724 &page_chart_regions,
6725 )
6726}
6727
6728fn compute_layout_complexity_with_chart_regions(
6729 items: &[types::TextItem],
6730 column_items: &[types::TextItem],
6731 rects: &[types::PdfRect],
6732 lines: &[types::PdfLine],
6733 page_chart_regions: &markdown::PageChartRegions,
6734) -> LayoutComplexity {
6735 use markdown::analysis::calculate_font_stats_from_items;
6736
6737 let mut seen_pages: Vec<u32> = items.iter().map(|i| i.page).collect();
6739 seen_pages.sort();
6740 seen_pages.dedup();
6741
6742 let font_stats = calculate_font_stats_from_items(items);
6743 let base_size = font_stats.most_common_size;
6744
6745 let mut pages_with_tables: Vec<u32> = Vec::new();
6748 for &page in &seen_pages {
6749 let page_items: Vec<&types::TextItem> = items.iter().filter(|i| i.page == page).collect();
6750
6751 let owned_items: Vec<types::TextItem> = page_items.iter().map(|i| (*i).clone()).collect();
6753 let page_content_width = tables::content_width(&owned_items);
6754 let bands = markdown::split_side_by_side(&owned_items);
6755 let chart_regions = page_chart_regions
6756 .get(&page)
6757 .map(Vec::as_slice)
6758 .unwrap_or_default();
6759
6760 let band_ranges: Vec<(f32, f32)> = if bands.is_empty() {
6761 vec![(f32::MIN, f32::MAX)]
6763 } else {
6764 bands
6765 };
6766
6767 let mut found_table = false;
6768 for &(x_lo, x_hi) in &band_ranges {
6769 let margin = 2.0;
6770 let band_items: Vec<types::TextItem> = owned_items
6771 .iter()
6772 .filter(|item| {
6773 (x_lo == f32::MIN || (item.x >= x_lo - margin && item.x < x_hi + margin))
6774 && !markdown::item_is_in_chart_region(item, chart_regions)
6775 })
6776 .cloned()
6777 .collect();
6778
6779 let band_rects: Vec<types::PdfRect> = if x_lo == f32::MIN {
6780 rects.iter().filter(|r| r.page == page).cloned().collect()
6781 } else {
6782 markdown::filter_rects_to_band(rects, page, x_lo, x_hi)
6783 };
6784
6785 let band_lines: Vec<types::PdfLine> = if x_lo == f32::MIN {
6786 lines.iter().filter(|l| l.page == page).cloned().collect()
6787 } else {
6788 markdown::filter_lines_to_band(lines, page, x_lo, x_hi)
6789 };
6790
6791 let has_data_table =
6796 |tables: &[tables::Table]| tables.iter().any(|t| t.kind == tables::TableKind::Data);
6797
6798 let (rect_tables, _) = tables::detect_tables_from_rects(&band_items, &band_rects, page);
6799 if has_data_table(&rect_tables) {
6800 found_table = true;
6801 break;
6802 }
6803 let line_tables = tables::detect_tables_from_lines(&band_items, &band_lines, page);
6804 if has_data_table(&line_tables) {
6805 found_table = true;
6806 break;
6807 }
6808 let heuristic_tables = tables::detect_tables_with_page_width(
6810 &band_items,
6811 base_size,
6812 false,
6813 page_content_width,
6814 );
6815 if has_data_table(&heuristic_tables) {
6816 found_table = true;
6817 break;
6818 }
6819 }
6820 if found_table {
6821 pages_with_tables.push(page);
6822 }
6823 }
6824
6825 let mut pages_with_columns: Vec<u32> = Vec::new();
6826 for &page in &seen_pages {
6827 let chart_regions = page_chart_regions
6828 .get(&page)
6829 .map(Vec::as_slice)
6830 .unwrap_or_default();
6831 let page_column_items: Vec<types::TextItem> = column_items
6832 .iter()
6833 .filter(|item| {
6834 item.page == page && !markdown::item_is_in_chart_region(item, chart_regions)
6835 })
6836 .cloned()
6837 .collect();
6838 let cols =
6839 extractor::detect_columns(&page_column_items, page, pages_with_tables.contains(&page));
6840 if cols.len() >= 2 {
6841 pages_with_columns.push(page);
6842 }
6843 }
6844
6845 let is_complex = !pages_with_tables.is_empty() || !pages_with_columns.is_empty();
6846
6847 LayoutComplexity {
6848 is_complex,
6849 pages_with_tables,
6850 pages_with_columns,
6851 }
6852}
6853
6854#[derive(Debug, thiserror::Error)]
6855pub enum PdfError {
6856 #[error("IO error: {0}")]
6857 Io(#[from] std::io::Error),
6858 #[error("PDF parsing error: {0}")]
6859 Parse(String),
6860 #[error("PDF is encrypted")]
6861 Encrypted,
6862 #[error("Invalid PDF structure")]
6863 InvalidStructure,
6864 #[error("Not a PDF: {0}")]
6865 NotAPdf(String),
6866}
6867
6868impl From<lopdf::Error> for PdfError {
6869 fn from(e: lopdf::Error) -> Self {
6870 match e {
6871 lopdf::Error::IO(io_err) => PdfError::Io(io_err),
6872 lopdf::Error::Decryption(_)
6873 | lopdf::Error::InvalidPassword
6874 | lopdf::Error::AlreadyEncrypted
6875 | lopdf::Error::UnsupportedSecurityHandler(_) => PdfError::Encrypted,
6876 lopdf::Error::Unimplemented(msg) if msg.contains("encrypted") => PdfError::Encrypted,
6877 lopdf::Error::Parse(ref pe) if pe.to_string().contains("invalid file header") => {
6878 PdfError::NotAPdf("invalid PDF file header".to_string())
6879 }
6880 lopdf::Error::MissingXrefEntry
6881 | lopdf::Error::Xref(_)
6882 | lopdf::Error::IndirectObject { .. }
6883 | lopdf::Error::ObjectIdMismatch
6884 | lopdf::Error::InvalidObjectStream(_)
6885 | lopdf::Error::InvalidOffset(_) => PdfError::InvalidStructure,
6886 other => PdfError::Parse(other.to_string()),
6887 }
6888 }
6889}
6890
6891pub(crate) fn is_encrypted_lopdf_error(e: &lopdf::Error) -> bool {
6893 matches!(
6894 e,
6895 lopdf::Error::Decryption(_)
6896 | lopdf::Error::InvalidPassword
6897 | lopdf::Error::AlreadyEncrypted
6898 | lopdf::Error::UnsupportedSecurityHandler(_)
6899 ) || matches!(e, lopdf::Error::Unimplemented(msg) if msg.contains("encrypted"))
6900}
6901
6902fn strip_bom_and_whitespace(bytes: &[u8]) -> &[u8] {
6908 let b = if bytes.starts_with(&[0xEF, 0xBB, 0xBF]) {
6909 &bytes[3..]
6910 } else {
6911 bytes
6912 };
6913 let start = b
6914 .iter()
6915 .position(|&c| !c.is_ascii_whitespace())
6916 .unwrap_or(b.len());
6917 &b[start..]
6918}
6919
6920fn starts_with_ci(haystack: &[u8], needle: &[u8]) -> bool {
6922 if haystack.len() < needle.len() {
6923 return false;
6924 }
6925 haystack[..needle.len()]
6926 .iter()
6927 .zip(needle)
6928 .all(|(a, b)| a.eq_ignore_ascii_case(b))
6929}
6930
6931fn detect_file_type_hint(bytes: &[u8]) -> String {
6933 if bytes.is_empty() {
6934 return "file is empty".to_string();
6935 }
6936
6937 let trimmed = strip_bom_and_whitespace(bytes);
6938
6939 if starts_with_ci(trimmed, b"<!doctype html")
6941 || starts_with_ci(trimmed, b"<html")
6942 || starts_with_ci(trimmed, b"<head")
6943 || starts_with_ci(trimmed, b"<body")
6944 {
6945 return "file appears to be HTML".to_string();
6946 }
6947
6948 if trimmed.starts_with(b"<?xml") || trimmed.starts_with(b"<") {
6950 if starts_with_ci(trimmed, b"<?xml") {
6951 return "file appears to be XML".to_string();
6952 }
6953 if trimmed.starts_with(b"<") && !trimmed.starts_with(b"<%") {
6954 return "file appears to be XML".to_string();
6955 }
6956 }
6957
6958 if trimmed.starts_with(b"{") || trimmed.starts_with(b"[") {
6960 return "file appears to be JSON".to_string();
6961 }
6962
6963 if bytes.starts_with(&[0x89, 0x50, 0x4E, 0x47]) {
6965 return "file appears to be a PNG image".to_string();
6966 }
6967
6968 if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
6970 return "file appears to be a JPEG image".to_string();
6971 }
6972
6973 if bytes.starts_with(&[0x50, 0x4B, 0x03, 0x04]) {
6975 return "file appears to be a ZIP archive (possibly an Office document)".to_string();
6976 }
6977
6978 let sample = &bytes[..bytes.len().min(512)];
6980 let printable = sample
6981 .iter()
6982 .filter(|&&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
6983 .count();
6984 if printable > sample.len() * 3 / 4 {
6985 return "file appears to be plain text".to_string();
6986 }
6987
6988 "file is not a PDF".to_string()
6989}
6990
6991const PDF_HEADER_SEARCH_WINDOW: usize = 1024;
6997
6998const PDF_HEADER_PROBE_LEN: usize = 9;
7000
7001fn pdf_header_offset(buffer: &[u8]) -> Option<usize> {
7012 let probe_end = (PDF_HEADER_SEARCH_WINDOW + PDF_HEADER_PROBE_LEN).min(buffer.len());
7015 let probe = &buffer[..probe_end];
7016
7017 let mut best: Option<(u8, usize)> = None;
7018 for (offset, window) in probe
7019 .windows(b"%PDF-".len())
7020 .enumerate()
7021 .take(PDF_HEADER_SEARCH_WINDOW)
7022 {
7023 if window != b"%PDF-" {
7024 continue;
7025 }
7026 let version = &probe[offset + b"%PDF-".len()..];
7027 let at_line_start = offset == 0 || matches!(probe[offset - 1], b'\r' | b'\n');
7028 let rank = if at_line_start && is_canonical_pdf_version_line(version) {
7029 return Some(offset);
7030 } else if version.first().is_some_and(u8::is_ascii_digit) {
7031 1
7032 } else {
7033 2
7034 };
7035 if best.is_none_or(|(best_rank, _)| rank < best_rank) {
7036 best = Some((rank, offset));
7037 }
7038 }
7039 best.map(|(_, offset)| offset)
7040}
7041
7042fn is_canonical_pdf_version_line(version: &[u8]) -> bool {
7045 matches!(version, [major, b'.', minor, rest @ ..]
7046 if major.is_ascii_digit()
7047 && minor.is_ascii_digit()
7048 && rest.first().is_none_or(|b| matches!(b, b'\r' | b'\n')))
7049}
7050
7051fn strip_leading_bytes_before_header(buffer: &[u8]) -> &[u8] {
7056 match pdf_header_offset(buffer) {
7057 Some(offset) if offset > 0 => {
7058 log::debug!("dropping {offset} leading bytes before the %PDF- header");
7059 &buffer[offset..]
7060 }
7061 _ => buffer,
7062 }
7063}
7064
7065pub(crate) fn validate_pdf_bytes(buffer: &[u8]) -> Result<(), PdfError> {
7071 if pdf_header_offset(buffer).is_some() {
7072 Ok(())
7073 } else {
7074 Err(PdfError::NotAPdf(detect_file_type_hint(buffer)))
7075 }
7076}
7077
7078pub(crate) fn io_error_at(path: &Path, err: std::io::Error) -> PdfError {
7082 let location = if path.is_absolute() {
7083 path.display().to_string()
7084 } else if let Ok(cwd) = std::env::current_dir() {
7085 format!("{} (working directory {})", path.display(), cwd.display())
7086 } else {
7087 path.display().to_string()
7088 };
7089 PdfError::Io(std::io::Error::new(
7090 err.kind(),
7091 format!("{location}: {err}"),
7092 ))
7093}
7094
7095pub(crate) fn read_file(path: &Path) -> Result<Vec<u8>, PdfError> {
7097 std::fs::read(path).map_err(|err| io_error_at(path, err))
7098}
7099
7100pub(crate) fn validate_pdf_file<P: AsRef<Path>>(path: P) -> Result<(), PdfError> {
7104 use std::io::Read;
7105 let path = path.as_ref();
7106 let mut file = std::fs::File::open(path).map_err(|err| io_error_at(path, err))?;
7107 let mut buf = [0u8; PDF_HEADER_SEARCH_WINDOW + PDF_HEADER_PROBE_LEN];
7108 let n = file.read(&mut buf).map_err(|err| io_error_at(path, err))?;
7109 validate_pdf_bytes(&buf[..n])
7110}
7111
7112#[cfg(test)]
7113mod tests {
7114 use super::*;
7115 use crate::types::ItemType;
7116
7117 #[test]
7118 fn missing_file_error_names_the_path() {
7119 let err = validate_pdf_file("this-file-does-not-exist.pdf").unwrap_err();
7120 let msg = err.to_string();
7121 assert!(
7122 msg.contains("this-file-does-not-exist.pdf"),
7123 "missing-file error should name the path, got {msg}"
7124 );
7125 assert!(
7126 msg.contains("working directory"),
7127 "relative path should name the working directory, got {msg}"
7128 );
7129 }
7130
7131 #[test]
7132 fn pdf_header_offset_finds_header_at_start() {
7133 assert_eq!(pdf_header_offset(b"%PDF-1.4\n%%EOF"), Some(0));
7134 }
7135
7136 #[test]
7137 fn pdf_header_offset_skips_bom_and_whitespace() {
7138 let mut buf = vec![0xEF, 0xBB, 0xBF, b'\n', b'\t'];
7139 buf.extend_from_slice(b"%PDF-1.7\n");
7140 assert_eq!(pdf_header_offset(&buf), Some(5));
7141 }
7142
7143 #[test]
7144 fn pdf_header_offset_finds_header_after_leading_bytes() {
7145 let prefix = b"--boundary\r\nContent-Type: application/pdf\r\n\r\n";
7147 let mut buf = prefix.to_vec();
7148 buf.extend_from_slice(b"%PDF-1.5\n%%EOF");
7149 assert_eq!(pdf_header_offset(&buf), Some(prefix.len()));
7150 }
7151
7152 #[test]
7153 fn pdf_header_offset_ignores_bare_marker_in_leading_text() {
7154 let buf = b"filename=\"%PDF.pdf\"\r\n\r\n%PDF-1.6\n";
7155 let expected = buf.len() - b"%PDF-1.6\n".len();
7156 assert_eq!(pdf_header_offset(buf), Some(expected));
7157 assert_eq!(pdf_header_offset(b"xx%PDF\n1 0 obj"), None);
7158 }
7159
7160 #[test]
7161 fn pdf_header_offset_accepts_dash_without_version_digit() {
7162 assert_eq!(pdf_header_offset(b"%PDF-\n1 0 obj"), Some(0));
7163 }
7164
7165 #[test]
7166 fn pdf_header_offset_accepts_header_at_window_edge() {
7167 let mut buf = vec![b' '; PDF_HEADER_SEARCH_WINDOW - 1];
7168 buf.extend_from_slice(b"%PDF-1.4\n");
7169 assert_eq!(pdf_header_offset(&buf), Some(PDF_HEADER_SEARCH_WINDOW - 1));
7170 }
7171
7172 #[test]
7173 fn pdf_header_offset_rejects_header_beyond_window() {
7174 let mut buf = vec![b' '; PDF_HEADER_SEARCH_WINDOW];
7175 buf.extend_from_slice(b"%PDF-1.4\n");
7176 assert_eq!(pdf_header_offset(&buf), None);
7177 assert_eq!(pdf_header_offset(b""), None);
7178 assert_eq!(pdf_header_offset(b"just some text"), None);
7179 }
7180
7181 #[test]
7182 fn pdf_header_offset_ranks_canonical_header_lines_first() {
7183 let buf = b"%PDF-x\n%PDF-1 note\n%PDF-1.4\n%PDF-2.0\r\n";
7185 assert_eq!(pdf_header_offset(buf), Some(19));
7186 let buf = b"X-Note: %PDF-1.4\n%PDF-1.7\n";
7188 assert_eq!(pdf_header_offset(buf), Some(17));
7189 assert_eq!(pdf_header_offset(b"%PDF-x\n%PDF-1 note\n"), Some(7));
7190 assert_eq!(pdf_header_offset(b"%PDF-x\n"), Some(0));
7191
7192 let mut buf = b"x: %PDF-1\n".repeat(50);
7194 buf.extend_from_slice(b"%PDF-1.4\n");
7195 assert_eq!(
7196 pdf_header_offset(&buf),
7197 Some(buf.len() - b"%PDF-1.4\n".len())
7198 );
7199 }
7200
7201 #[test]
7202 fn strip_leading_bytes_before_header_slices_to_header() {
7203 let buf = b"junk\r\n%PDF-1.4\n%%EOF";
7204 assert_eq!(strip_leading_bytes_before_header(buf), b"%PDF-1.4\n%%EOF");
7205 let clean = b"%PDF-1.4\n%%EOF";
7206 assert_eq!(strip_leading_bytes_before_header(clean), clean);
7207 let none = b"not a pdf";
7208 assert_eq!(strip_leading_bytes_before_header(none), none);
7209 }
7210
7211 #[test]
7212 fn validate_pdf_bytes_accepts_leading_bytes_and_rejects_text() {
7213 assert!(validate_pdf_bytes(b"--b\r\n\r\n%PDF-1.4\n").is_ok());
7214 match validate_pdf_bytes(b"This is plain text mentioning %PDF, not a PDF.") {
7215 Err(PdfError::NotAPdf(hint)) => assert!(hint.contains("plain text"), "{hint}"),
7216 other => panic!("expected NotAPdf, got {other:?}"),
7217 }
7218 }
7219
7220 fn test_item(text: &str, x: f32, y: f32, width: f32, height: f32) -> TextItem {
7221 TextItem {
7222 text: text.to_string(),
7223 x,
7224 y,
7225 width,
7226 height,
7227 font: "Helvetica".to_string(),
7228 font_tag: String::new(),
7229 legacy_symbol_rewrite: false,
7230 font_size: height,
7231 page: 1,
7232 is_bold: false,
7233 is_italic: false,
7234 font_weight: None,
7235 bold_source: None,
7236 fixed_pitch: None,
7237 fill_color: None,
7238 stroke_color: None,
7239 render_mode: None,
7240 is_underline: false,
7241 is_strikeout: false,
7242 rotation: 0.0,
7243 advance_known: true,
7244 item_type: ItemType::Text,
7245 mcid: None,
7246 baseline_shift: 0.0,
7247 }
7248 }
7249
7250 fn test_text_item_on_page(page: u32, text: &str) -> TextItem {
7251 TextItem {
7252 page,
7253 ..test_item(text, 10.0, 10.0, text.len() as f32 * 5.0, 12.0)
7254 }
7255 }
7256
7257 fn test_image_item(width: f32, height: f32) -> TextItem {
7258 TextItem {
7259 item_type: ItemType::Image,
7260 ..test_item("[Image]", 20.0, 30.0, width, height)
7261 }
7262 }
7263
7264 #[test]
7265 fn supplemental_ocr_regions_require_substantial_physical_images() {
7266 let substantial = supplemental_ocr_image_region(&test_image_item(200.0, 120.0)).unwrap();
7267 assert_eq!(substantial.width, 200.0);
7268 assert_eq!(substantial.height, 120.0);
7269
7270 assert!(supplemental_ocr_image_region(&test_image_item(100.0, 200.0)).is_none());
7271 assert!(supplemental_ocr_image_region(&test_image_item(200.0, 60.0)).is_none());
7272 assert!(supplemental_ocr_image_region(&test_image_item(120.0, 100.0)).is_none());
7273 assert!(
7274 supplemental_ocr_image_region(&test_item("text", 0.0, 0.0, 300.0, 300.0)).is_none()
7275 );
7276 }
7277
7278 #[test]
7279 fn supplemental_ocr_regions_normalize_negative_image_dimensions() {
7280 let region = supplemental_ocr_image_region(&test_image_item(-200.0, -120.0)).unwrap();
7281 assert_eq!(region.x, -180.0);
7282 assert_eq!(region.y, -90.0);
7283 assert_eq!(region.width, 200.0);
7284 assert_eq!(region.height, 120.0);
7285 }
7286
7287 #[test]
7288 fn removed_sparse_folios_leave_no_layout_evidence() {
7289 let items = vec![
7290 test_item("1", 25.0, 20.0, 12.0, 10.0),
7291 test_item("2", 520.0, 60.0, 12.0, 10.0),
7292 ];
7293 let (filtered, _, _) =
7294 extractor::filter_markdown_page_numbers_with_removed_pages(items.clone(), 1);
7295 assert!(filtered.is_empty());
7296
7297 let filtered = compute_layout_complexity(&items, &filtered, &[], &[]);
7298
7299 assert!(!filtered.is_complex);
7300 assert!(filtered.pages_with_tables.is_empty());
7301 assert!(filtered.pages_with_columns.is_empty());
7302 }
7303
7304 #[test]
7305 fn dense_chart_panel_is_not_reported_as_a_table() {
7306 let mut items: Vec<TextItem> = (0..8)
7307 .flat_map(|row| {
7308 (0..6).map(move |column| {
7309 test_item(
7310 &format!("{}", row * 10 + column),
7311 105.0 + column as f32 * 35.0,
7312 525.0 - row as f32 * 15.0,
7313 24.0,
7314 10.0,
7315 )
7316 })
7317 })
7318 .collect();
7319 for row in 0..6 {
7320 items.push(test_item(
7321 "Left column prose continues here",
7322 80.0,
7323 320.0 - row as f32 * 15.0,
7324 160.0,
7325 10.0,
7326 ));
7327 items.push(test_item(
7328 "Right column prose continues here",
7329 300.0,
7330 320.0 - row as f32 * 15.0,
7331 160.0,
7332 10.0,
7333 ));
7334 }
7335 let mut lines: Vec<PdfLine> = (0..30)
7336 .map(|column| PdfLine {
7337 x1: 100.0 + column as f32 * 8.0,
7338 y1: 400.0,
7339 x2: 100.0 + column as f32 * 8.0,
7340 y2: 550.0,
7341 page: 1,
7342 })
7343 .collect();
7344 lines.extend((0..6).map(|row| PdfLine {
7345 x1: 100.0,
7346 y1: 400.0 + row as f32 * 30.0,
7347 x2: 332.0,
7348 y2: 400.0 + row as f32 * 30.0,
7349 page: 1,
7350 }));
7351 let rects = vec![PdfRect {
7352 x: 80.0,
7353 y: 350.0,
7354 width: 280.0,
7355 height: 240.0,
7356 page: 1,
7357 }];
7358
7359 let complexity = compute_layout_complexity(&items, &items, &rects, &lines);
7360
7361 assert!(complexity.pages_with_tables.is_empty());
7362 }
7363
7364 #[test]
7365 fn page_selection_keeps_document_wide_folio_layout_decisions() {
7366 let mut items = Vec::new();
7367 for page in 1..=4 {
7368 for row in 0..8 {
7369 let y = 20.0 + row as f32 * 8.0;
7370 let mut folio = test_item(&(row * 10 + page).to_string(), 25.0, y, 12.0, 10.0);
7371 folio.page = page;
7372 let mut footer =
7373 test_item(&format!("Footer row {row} summary"), 43.0, y, 470.0, 10.0);
7374 footer.page = page;
7375 let mut body = test_item(&format!("Body{row}"), 530.0, y, 55.0, 10.0);
7376 body.page = page;
7377 items.extend([folio, footer, body]);
7378 }
7379 }
7380
7381 let page_one_items: Vec<_> = items
7382 .iter()
7383 .filter(|item| item.page == 1)
7384 .cloned()
7385 .collect();
7386 let (page_local_layout, _, _) =
7387 extractor::filter_markdown_page_numbers_with_removed_pages(page_one_items.clone(), 4);
7388 let page_local = compute_layout_complexity(&page_one_items, &page_local_layout, &[], &[]);
7389 assert!(
7390 page_local.pages_with_columns.contains(&1),
7391 "fixture must reproduce page-local folio column evidence"
7392 );
7393
7394 let selected =
7395 select_items_with_document_folio_context(items, 4, Some(&HashSet::from([1])));
7396 assert_eq!(
7397 selected
7398 .removal_mask
7399 .iter()
7400 .filter(|remove| **remove)
7401 .count(),
7402 8
7403 );
7404 let document_wide =
7405 compute_layout_complexity(&selected.items, &selected.layout_items, &[], &[]);
7406 assert!(!document_wide.pages_with_columns.contains(&1));
7407 }
7408
7409 #[test]
7410 fn test_detect_encoding_issues_fffd() {
7411 assert!(detect_encoding_issues(
7412 "Some text with \u{FFFD} replacement"
7413 ));
7414 }
7415
7416 #[test]
7417 fn test_detect_encoding_issues_dollar_as_space() {
7418 let garbled = "Last$advanced$Book$Programm$3th$Workshop$on$Chest$Wall$Deformities$and$More";
7420 assert!(detect_encoding_issues(garbled));
7421 }
7422
7423 #[test]
7424 fn test_detect_encoding_issues_financial_text() {
7425 let financial = "Revenue was $100M in Q1, up from $90M. Costs: $50M, $30M, $20M, $15M, $12M, $8M, $5M, $3M, $2M, $1M, $500K.";
7427 assert!(!detect_encoding_issues(financial));
7428 }
7429
7430 #[test]
7431 fn test_detect_encoding_issues_clean_text() {
7432 assert!(!detect_encoding_issues(
7433 "Normal markdown text with no issues."
7434 ));
7435 }
7436
7437 #[test]
7438 fn test_detect_encoding_issues_few_dollars() {
7439 let text = "a$b c$d e$f";
7441 assert!(!detect_encoding_issues(text));
7442 }
7443
7444 const SHIFTED_CIPHER_TEXT: &str =
7449 "8VceZWZTReVW9VdZXReZdhZeYcVdaVTeeHVcZVd8EcVWVccVUHeT:iYZSZe-(,e2'WZ]V \
7450 ;VScfRcj2&,*,*$ .'R CZdecfVehYZTYUVWZVdeYVcZXYedWY]UVcdW]X'eVcUVSeYVcVXZdecReRUR]]ed \
7451 TCBGC@ZUReVUdfSdZUZRcZVdZdWZ]VUYVcVhZeYafcdfReeVXf]ReZV0*S$.$ZZZ$6$&ViTVae \
7452 WceYVZdecfVedcVWVccVUe.'S&.'T&.'U&.'V&.'WSV]h(EfcdfReeYZdcVXf]ReZYV \
7453 cVXZdecReYVcVSjRXcVVdeWfcZdYRTajWRjdfTYZdecfVeWZ]VUYVcVhZeYeYVH:8 \
7454 cVbfVde( .'S fRcRejWTVceRZZXReZTZWZT7V]]IV]VaYV8(RUHfeYhVdeVc7V]]IV]VaYV8 \
7455 :iYZSZe.'TeYVaVcZUVUZX9VTVSVc-&,*$";
7456
7457 #[test]
7458 fn test_detect_encoding_issues_shifted_cipher_text() {
7459 assert!(detect_encoding_issues(SHIFTED_CIPHER_TEXT));
7460 }
7461
7462 #[test]
7463 fn test_shifted_cipher_below_sample_threshold_not_flagged() {
7464 assert!(!detect_encoding_issues("8VceZWZTReVW9VdZXReZdhZeY"));
7466 }
7467
7468 #[test]
7469 fn test_clean_prose_not_flagged_as_cipher() {
7470 let prose = "Certificate of Designations with respect to Series C Preferred Stock, \
7471 filed February 18, 2020. No instrument which defines the rights of holders of \
7472 long-term debt of the registrant and all of its consolidated subsidiaries is \
7473 filed herewith pursuant to Regulation S-K, Item 601. Pursuant to this regulation, \
7474 the registrant hereby agrees to furnish a copy of any such instrument to the SEC \
7475 upon request.";
7476 assert!(!detect_encoding_issues(prose));
7477 }
7478
7479 #[test]
7480 fn test_camel_case_code_not_flagged_as_cipher() {
7481 let code = "The getElementById and querySelectorAll methods return DOM nodes. Use \
7482 addEventListener with removeEventListener, requestAnimationFrame with \
7483 cancelAnimationFrame, and setTimeout with clearTimeout. The XMLHttpRequest \
7484 object exposes onreadystatechange, responseText and getAllResponseHeaders. \
7485 Prefer createElement, appendChild, insertBefore and replaceChild for DOM \
7486 manipulation, and getBoundingClientRect for layout measurement.";
7487 assert!(!detect_encoding_issues(code));
7488 }
7489
7490 #[test]
7491 fn test_accented_european_text_not_flagged_as_cipher() {
7492 let swedish = "Regeringen föreslår att riksdagen antar förslaget till lag om ändring \
7493 i skatteförfarandelagen. Bestämmelserna föreslås träda i kraft den första januari. \
7494 Förslaget innebär att företag med säte i utlandet måste lämna särskilda uppgifter \
7495 till Skatteverket varje kvartal, och att avgiften höjs för överträdelser av de nya \
7496 bestämmelserna om rapporteringsskyldighet för gränsöverskridande arrangemang.";
7497 assert!(!detect_encoding_issues(swedish));
7498 }
7499
7500 #[test]
7501 fn test_all_caps_text_not_flagged_as_cipher() {
7502 let caps = "EXHIBIT INDEX PURSUANT TO ITEM 601 OF REGULATION SK CERTIFICATE OF \
7503 DESIGNATIONS WITH RESPECT TO SERIES C PREFERRED STOCK FILED FEBRUARY EIGHTEEN \
7504 TWENTY TWENTY AND INCORPORATED HEREIN BY REFERENCE TO THE ANNUAL REPORT ON FORM \
7505 TENK FOR THE PERIOD ENDED DECEMBER THIRTYFIRST TWENTY NINETEEN AS AMENDED";
7506 assert!(!detect_encoding_issues(caps));
7507 }
7508
7509 const CAESAR_PROSE: &str =
7514 "The registrant hereby agrees to furnish a copy of any such instrument to the \
7515 Commission upon request. This certificate of designations was filed February with \
7516 respect to Series Preferred Stock and incorporated herein by reference to the annual \
7517 report on form for the period ended December as amended and restated thereafter.";
7518
7519 fn caesar_shift(text: &str, k: u8) -> String {
7520 text.chars()
7521 .map(|c| match c {
7522 'a'..='z' => (((c as u8 - b'a' + k) % 26) + b'a') as char,
7523 'A'..='Z' => (((c as u8 - b'A' + k) % 26) + b'A') as char,
7524 _ => c,
7525 })
7526 .collect()
7527 }
7528
7529 #[test]
7530 fn test_mixed_case_caesar_shift_flagged() {
7531 assert!(detect_encoding_issues(&caesar_shift(CAESAR_PROSE, 3)));
7532 }
7533
7534 #[test]
7535 fn test_all_lowercase_caesar_shift_flagged() {
7536 assert!(detect_encoding_issues(&caesar_shift(
7540 &CAESAR_PROSE.to_lowercase(),
7541 5
7542 )));
7543 }
7544
7545 #[test]
7546 fn test_all_uppercase_caesar_shift_flagged() {
7547 assert!(detect_encoding_issues(&caesar_shift(
7548 &CAESAR_PROSE.to_uppercase(),
7549 7
7550 )));
7551 }
7552
7553 #[test]
7554 fn test_dna_sequence_not_flagged_as_cipher() {
7555 let dna = "ACGT".repeat(120);
7559 assert!(!detect_encoding_issues(&dna));
7560 }
7561
7562 #[test]
7563 fn test_protein_sequence_not_flagged_as_cipher() {
7564 let protein =
7565 "MKTAYIAKQRQISFVKSHFSRQLEERLGLIEVQAPILSRVGDGTQDNLSGAEKAVQVKVKALPDAQFEVVHSLAKWKR"
7566 .repeat(6);
7567 assert!(!detect_encoding_issues(&protein));
7568 }
7569
7570 #[test]
7571 fn test_ticker_list_not_flagged_as_cipher() {
7572 let tickers = "AAPL MSFT GOOG TSLA NVDA AMZN META NFLX AMD INTC CSCO ORCL CRM ADBE QCOM \
7573 TXN AVGO MU LRCX KLAC ASML SNPS CDNS FTNT PANW "
7574 .repeat(4);
7575 assert!(!detect_encoding_issues(&tickers));
7576 }
7577
7578 #[test]
7579 fn test_text_quality_flags_shifted_cipher_page() {
7580 let items = vec![
7581 test_text_item_on_page(1, SHIFTED_CIPHER_TEXT),
7582 test_text_item_on_page(2, "A clean second page should not be routed to OCR."),
7583 ];
7584
7585 let quality = analyze_text_quality(&items);
7586
7587 assert!(quality.has_encoding_issues);
7588 assert_eq!(quality.pages_needing_ocr, vec![1]);
7589 assert_eq!(
7590 quality.reasons_by_page.get(&1).cloned(),
7591 Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7592 );
7593 }
7594
7595 #[test]
7596 fn test_text_quality_cipher_stats_accumulate_across_items() {
7597 let items: Vec<TextItem> = SHIFTED_CIPHER_TEXT
7600 .split_whitespace()
7601 .map(|chunk| test_text_item_on_page(1, chunk))
7602 .collect();
7603
7604 let quality = analyze_text_quality(&items);
7605
7606 assert_eq!(quality.pages_needing_ocr, vec![1]);
7607 }
7608
7609 #[test]
7610 fn test_text_quality_flags_localized_cid_mojibake_span() {
7611 let items = vec![
7612 test_text_item_on_page(
7613 1,
7614 "Waiting Period 等待期 Maternity and newborn infant care benefit",
7615 ),
7616 test_text_item_on_page(
7617 1,
7618 "Inpatient and Day-care Benefits DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯",
7619 ),
7620 test_text_item_on_page(1, "Covered up to annual maximum. 赔付至年度最高保额。"),
7621 test_text_item_on_page(2, "A clean second page should not be routed to OCR."),
7622 ];
7623
7624 let quality = analyze_text_quality(&items);
7625
7626 assert!(quality.has_encoding_issues);
7627 assert_eq!(quality.pages_needing_ocr, vec![1]);
7628 assert_eq!(
7629 quality.reasons_by_page.get(&1).cloned(),
7630 Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7631 );
7632 }
7633
7634 #[test]
7635 fn test_text_quality_flags_replacement_and_private_use_runs() {
7636 let items = vec![
7637 test_text_item_on_page(1, "broken \u{FFFD}\u{FFFD} text"),
7638 test_text_item_on_page(3, "\u{E000}\u{E001}\u{E002}"),
7639 ];
7640
7641 let quality = analyze_text_quality(&items);
7642
7643 assert_eq!(quality.pages_needing_ocr, vec![1, 3]);
7644 assert_eq!(
7645 quality.reasons_by_page.get(&1).cloned(),
7646 Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7647 );
7648 assert_eq!(
7649 quality.reasons_by_page.get(&3).cloned(),
7650 Some(vec![OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()])
7651 );
7652 }
7653
7654 #[test]
7655 fn test_text_quality_allows_clean_multilingual_and_latin1_text() {
7656 let items = vec![
7657 test_text_item_on_page(1, "你好世界,这是一段正常的中文文本。"),
7658 test_text_item_on_page(1, "Résumé déjà vu: façade, São Paulo, año 2026."),
7659 test_text_item_on_page(1, "A single icon \u{E000} should not force OCR."),
7660 ];
7661
7662 let quality = analyze_text_quality(&items);
7663
7664 assert!(!quality.has_encoding_issues);
7665 assert!(quality.pages_needing_ocr.is_empty());
7666 assert!(quality.reasons_by_page.is_empty());
7667 }
7668
7669 #[test]
7670 fn test_text_quality_allows_toc_leaders_form_rules_and_short_math() {
7671 let items = vec![
7672 test_text_item_on_page(
7673 1,
7674 "Feature Overview ........................................................................................................ 1-5",
7675 ),
7676 test_text_item_on_page(1, "Signature __________________________________________"),
7677 test_text_item_on_page(1, "__________________________________________________"),
7678 test_text_item_on_page(1, "2×()×"),
7679 ];
7680
7681 let quality = analyze_text_quality(&items);
7682
7683 assert!(!quality.has_encoding_issues);
7684 assert!(quality.pages_needing_ocr.is_empty());
7685 }
7686
7687 #[test]
7688 fn test_text_quality_allows_isolated_replacement_character() {
7689 let items = vec![
7690 test_text_item_on_page(1, "\u{FFFD}2026 FINRA"),
7691 test_text_item_on_page(1, "A mostly clean page should not be sent to OCR."),
7692 ];
7693
7694 let quality = analyze_text_quality(&items);
7695
7696 assert!(!quality.has_encoding_issues);
7697 assert!(quality.pages_needing_ocr.is_empty());
7698 }
7699
7700 #[test]
7701 fn test_text_quality_allows_formula_replacement_on_clean_page() {
7702 let items = vec![
7703 test_text_item_on_page(
7704 1,
7705 "The LCOE of a power plant can be decomposed into three parts and described in prose.",
7706 ),
7707 test_text_item_on_page(
7708 1,
7709 "This page has enough normal text that a damaged equation should not force OCR.",
7710 ),
7711 test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} = x + y"),
7712 test_text_item_on_page(1, "More normal explanatory text follows after the formula."),
7713 ];
7714
7715 let quality = analyze_text_quality(&items);
7716
7717 assert!(!quality.has_encoding_issues);
7718 assert!(quality.pages_needing_ocr.is_empty());
7719 }
7720
7721 #[test]
7722 fn test_text_quality_flags_dense_replacement_text_page() {
7723 let items = vec![
7724 test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} broken layer"),
7725 test_text_item_on_page(1, "more \u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD} broken text"),
7726 test_text_item_on_page(1, "\u{FFFD}\u{FFFD}\u{FFFD}\u{FFFD}"),
7727 ];
7728
7729 let quality = analyze_text_quality(&items);
7730
7731 assert_eq!(quality.pages_needing_ocr, vec![1]);
7732 assert!(quality.has_encoding_issues);
7733 }
7734
7735 #[test]
7736 fn test_text_quality_allows_tex_ligature_c1_controls_in_words() {
7737 let items = vec![test_text_item_on_page(
7738 1,
7739 "Especialmente de\u{85}ciente es nuestro conocimiento del control. The amniotic \u{87}uid is important.",
7740 )];
7741
7742 let quality = analyze_text_quality(&items);
7743
7744 assert!(!quality.has_encoding_issues);
7745 assert!(quality.pages_needing_ocr.is_empty());
7746 }
7747
7748 #[test]
7749 fn test_region_text_quality_is_scoped_to_matched_items() {
7750 let clean_region = vec![
7751 test_text_item_on_page(1, "Clean native text"),
7752 test_text_item_on_page(1, "Résumé déjà vu"),
7753 ];
7754 let garbled_region = vec![
7755 test_text_item_on_page(1, "Clean prefix"),
7756 test_text_item_on_page(1, "DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯"),
7757 ];
7758
7759 assert!(!region_items_have_decoding_issue(&clean_region));
7760 assert!(region_items_have_decoding_issue(&garbled_region));
7761 }
7762
7763 #[test]
7764 fn test_garbage_text_detection() {
7765 let garbage = ",&<X ~%5&8-!A ~*(!,-!U (/#!U X ~#/=U 9/%*(!U !( X \
7768 (%U-(-/ V %&((8-#&&< *,(6--< %5&8-!( (,(/! #/<5U X \
7769 º&( >/5 /5&(#(8-!5 *,(6--( *,%@/-A W";
7770 assert!(is_garbage_text(garbage));
7771
7772 let normal = "This is a normal paragraph with words and sentences that contains enough characters to pass the threshold.";
7774 assert!(!is_garbage_text(normal));
7775
7776 let cyrillic =
7778 "Роботизированные технологии комплексы для производства металлургических предприятий";
7779 assert!(!is_garbage_text(cyrillic));
7780 }
7781
7782 #[test]
7783 fn test_cid_garbage_detection() {
7784 let cid_garbage = "Ë>íÓ\tý\r\u{0088}æ&Ït\u{0094}äí;\ný;wAL¢©èåD\rü£\
7787 qq\u{0096}¶Í Æ\réá; Ô 7G\u{008B}ý;èÕç¢ £ ý;C";
7788 assert!(
7789 is_cid_garbage(cid_garbage),
7790 "CID garbage with C1 controls should be detected"
7791 );
7792
7793 let korean = "본 가격표는 국내 거주 중인 외국인을 위한 한국어 가격표의 비공식 번역본입니다";
7795 assert!(
7796 !is_cid_garbage(korean),
7797 "Valid Korean text should not be flagged as garbage"
7798 );
7799
7800 let japanese = "羽田空港新飛行経路に係る航空機騒音の測定結果";
7802 assert!(
7803 !is_cid_garbage(japanese),
7804 "Valid Japanese text should not be flagged as garbage"
7805 );
7806
7807 let japanese_toc = "第1章 市政経営方針の位置づけ ···································· 1";
7808 assert!(
7809 !is_cid_garbage(japanese_toc),
7810 "Japanese TOC dot leaders should not be flagged as garbage"
7811 );
7812
7813 let tex_ligature = "amniotic \u{87}uid volume regulation";
7814 assert!(
7815 !is_cid_garbage(tex_ligature),
7816 "A single TeX ligature byte inside a word should not be CID garbage"
7817 );
7818 }
7819
7820 #[test]
7821 fn tsr_text_fill_does_not_pull_neighboring_overlapping_rows() {
7822 use crate::tables::structured::normalize_cell_bands;
7823 use crate::tables::StructuredCell;
7824
7825 let items = vec![
7826 test_item("Branch Name", 12.0, 88.0, 55.0, 8.0),
7827 test_item("Deposits", 112.0, 88.0, 36.0, 8.0),
7828 test_item("Oak Street", 12.0, 72.0, 48.0, 8.0),
7829 test_item("100", 112.0, 72.0, 18.0, 8.0),
7830 test_item("Boardwalk", 12.0, 55.2, 46.0, 8.0),
7831 test_item("200", 112.0, 55.2, 18.0, 8.0),
7832 ];
7833 let mut cells = vec![
7834 StructuredCell {
7835 row: 0,
7836 col: 0,
7837 rowspan: 1,
7838 colspan: 1,
7839 is_header: true,
7840 text: String::new(),
7841 page_pt_bbox: [10.0, 100.0, 100.0, 125.0],
7842 },
7843 StructuredCell {
7844 row: 0,
7845 col: 1,
7846 rowspan: 1,
7847 colspan: 1,
7848 is_header: true,
7849 text: String::new(),
7850 page_pt_bbox: [100.0, 100.0, 170.0, 125.0],
7851 },
7852 StructuredCell {
7853 row: 1,
7854 col: 0,
7855 rowspan: 1,
7856 colspan: 1,
7857 is_header: false,
7858 text: String::new(),
7859 page_pt_bbox: [10.0, 116.0, 100.0, 141.0],
7860 },
7861 StructuredCell {
7862 row: 1,
7863 col: 1,
7864 rowspan: 1,
7865 colspan: 1,
7866 is_header: false,
7867 text: String::new(),
7868 page_pt_bbox: [100.0, 116.0, 170.0, 141.0],
7869 },
7870 StructuredCell {
7871 row: 2,
7872 col: 0,
7873 rowspan: 1,
7874 colspan: 1,
7875 is_header: false,
7876 text: String::new(),
7877 page_pt_bbox: [10.0, 132.8, 100.0, 157.8],
7878 },
7879 StructuredCell {
7880 row: 2,
7881 col: 1,
7882 rowspan: 1,
7883 colspan: 1,
7884 is_header: false,
7885 text: String::new(),
7886 page_pt_bbox: [100.0, 132.8, 170.0, 157.8],
7887 },
7888 ];
7889
7890 normalize_cell_bands(&mut cells);
7891 for cell in &mut cells {
7892 let [x1, y1, x2, y2] = cell.page_pt_bbox;
7893 cell.text = collect_text_in_tsr_cell(
7894 &items,
7895 x1,
7896 y1,
7897 x2,
7898 y2,
7899 200.0,
7900 RegionCoordSpace::Standard,
7901 0.10,
7902 );
7903 }
7904
7905 assert_eq!(cells[0].text, "Branch Name");
7906 assert_eq!(cells[2].text, "Oak Street");
7907 assert_eq!(cells[4].text, "Boardwalk");
7908 assert!(!cells[0].text.contains("Oak Street"));
7909 assert!(!cells[2].text.contains("Branch Name"));
7910 assert!(!cells[2].text.contains("Boardwalk"));
7911 }
7912
7913 #[test]
7914 fn multi_row_expansion_splits_overstuffed_tsr_row() {
7915 use crate::tables::{cells_to_markdown, StructuredCell};
7916
7917 let items = vec![
7918 test_item("Branch Name", 20.0, 166.0, 55.0, 8.0),
7919 test_item("Deposits", 120.0, 166.0, 36.0, 8.0),
7920 test_item("Oak Street", 20.0, 136.0, 48.0, 8.0),
7921 test_item("100", 120.0, 136.0, 18.0, 8.0),
7922 test_item("Boardwalk", 20.0, 116.0, 46.0, 8.0),
7923 test_item("200", 120.0, 116.0, 18.0, 8.0),
7924 ];
7925 let cells = vec![
7926 StructuredCell {
7927 row: 0,
7928 col: 0,
7929 rowspan: 1,
7930 colspan: 1,
7931 is_header: true,
7932 text: "Branch Name".into(),
7933 page_pt_bbox: [10.0, 20.0, 100.0, 40.0],
7934 },
7935 StructuredCell {
7936 row: 0,
7937 col: 1,
7938 rowspan: 1,
7939 colspan: 1,
7940 is_header: true,
7941 text: "Deposits".into(),
7942 page_pt_bbox: [110.0, 20.0, 190.0, 40.0],
7943 },
7944 StructuredCell {
7945 row: 1,
7946 col: 0,
7947 rowspan: 1,
7948 colspan: 1,
7949 is_header: false,
7950 text: "Oak Street Boardwalk".into(),
7951 page_pt_bbox: [10.0, 40.0, 100.0, 100.0],
7952 },
7953 StructuredCell {
7954 row: 1,
7955 col: 1,
7956 rowspan: 1,
7957 colspan: 1,
7958 is_header: false,
7959 text: "100 200".into(),
7960 page_pt_bbox: [110.0, 40.0, 190.0, 100.0],
7961 },
7962 ];
7963
7964 let expanded =
7965 try_expand_multi_row_cells(&cells, &items, 200.0, RegionCoordSpace::Standard, 0.10)
7966 .expect("overstuffed data row should expand");
7967 let md = cells_to_markdown(&expanded);
7968
7969 assert_eq!(expanded.iter().map(|c| c.row).max().unwrap() + 1, 3);
7970 assert!(
7971 md.contains("|Oak Street|100|"),
7972 "missing first data row: {md}"
7973 );
7974 assert!(
7975 md.contains("|Boardwalk|200|"),
7976 "missing second data row: {md}"
7977 );
7978 assert!(
7979 !md.contains("Oak Street Boardwalk"),
7980 "compressed cell text should be replaced: {md}"
7981 );
7982 }
7983
7984 #[test]
7985 fn tsr_assignment_caps_uses_median_geometry() {
7986 use crate::tables::StructuredCell;
7987 let cells = vec![
7988 StructuredCell {
7989 row: 0,
7990 col: 0,
7991 rowspan: 1,
7992 colspan: 1,
7993 is_header: false,
7994 text: String::new(),
7995 page_pt_bbox: [0.0, 0.0, 100.0, 20.0], },
7997 StructuredCell {
7998 row: 0,
7999 col: 1,
8000 rowspan: 1,
8001 colspan: 1,
8002 is_header: false,
8003 text: String::new(),
8004 page_pt_bbox: [100.0, 0.0, 200.0, 20.0],
8005 },
8006 StructuredCell {
8007 row: 1,
8008 col: 0,
8009 rowspan: 1,
8010 colspan: 1,
8011 is_header: false,
8012 text: String::new(),
8013 page_pt_bbox: [0.0, 20.0, 100.0, 40.0],
8014 },
8015 ];
8016 let (cap_x, cap_y) = tsr_assignment_caps(&cells);
8017 assert_eq!(cap_x, 100.0);
8018 assert_eq!(cap_y, 20.0);
8019 }
8020
8021 #[test]
8022 fn tsr_assignment_caps_floor_protects_degenerate_input() {
8023 use crate::tables::StructuredCell;
8024 let cells = vec![StructuredCell {
8025 row: 0,
8026 col: 0,
8027 rowspan: 1,
8028 colspan: 1,
8029 is_header: false,
8030 text: String::new(),
8031 page_pt_bbox: [0.0, 0.0, 1.0, 1.0],
8032 }];
8033 let (cap_x, cap_y) = tsr_assignment_caps(&cells);
8034 assert_eq!(cap_x, 5.0);
8035 assert_eq!(cap_y, 5.0);
8036 }
8037
8038 #[test]
8039 fn stage2_recovers_left_aligned_header_text_outside_data_band() {
8040 use crate::tables::StructuredCell;
8049 let items = vec![
8050 test_item("Address", 175.0, 104.0, 50.0, 8.0),
8054 test_item("205 W Oak St", 420.0, 84.0, 100.0, 8.0),
8056 test_item("155 E Boardwalk Dr", 420.0, 64.0, 100.0, 8.0),
8058 ];
8059 let mut cells = vec![
8063 StructuredCell {
8064 row: 0,
8065 col: 0,
8066 rowspan: 1,
8067 colspan: 1,
8068 is_header: true,
8069 text: String::new(),
8070 page_pt_bbox: [410.0, 88.0, 700.0, 100.0],
8071 },
8072 StructuredCell {
8073 row: 1,
8074 col: 0,
8075 rowspan: 1,
8076 colspan: 1,
8077 is_header: false,
8078 text: String::new(),
8079 page_pt_bbox: [410.0, 108.0, 700.0, 116.0],
8080 },
8081 StructuredCell {
8082 row: 2,
8083 col: 0,
8084 rowspan: 1,
8085 colspan: 1,
8086 is_header: false,
8087 text: String::new(),
8088 page_pt_bbox: [410.0, 128.0, 700.0, 136.0],
8089 },
8090 ];
8091 let page_h = 200.0;
8092
8093 let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8095 for cell in &mut cells {
8096 let [x1, y1, x2, y2] = cell.page_pt_bbox;
8097 let bounds = region_bounds(x1, y1, x2, y2, page_h, RegionCoordSpace::Standard);
8098 let mut matched: Vec<TextItem> = Vec::new();
8099 for (i, item) in items.iter().enumerate() {
8100 if tsr_region_contains_item(item, bounds) {
8101 claimed.insert(i);
8102 matched.push(item.clone());
8103 }
8104 }
8105 cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
8106 }
8107 assert_eq!(cells[0].text, "", "header should be empty after stage 1");
8109 assert!(
8111 cells[1].text.contains("Oak"),
8112 "data row 1 should contain Oak: got {:?}",
8113 cells[1].text
8114 );
8115 assert!(
8116 cells[2].text.contains("Boardwalk"),
8117 "data row 2 should contain Boardwalk: got {:?}",
8118 cells[2].text
8119 );
8120
8121 tsr_assign_orphan_items(
8123 &items,
8124 &mut cells,
8125 &claimed,
8126 page_h,
8127 RegionCoordSpace::Standard,
8128 );
8129 assert_eq!(cells[0].text, "Address");
8130 assert!(!cells[1].text.contains("Address"));
8132 assert!(!cells[2].text.contains("Address"));
8133 }
8134
8135 #[test]
8136 fn stage2_recovers_y_shifted_col0_in_consecutive_rows() {
8137 use crate::tables::StructuredCell;
8147 let items = vec![
8148 test_item("Bellevue", 30.0, 235.0, 45.0, 8.0),
8151 test_item("Glenwood", 30.0, 215.0, 45.0, 8.0),
8153 test_item("Metro Crossing", 30.0, 195.0, 70.0, 8.0),
8155 ];
8156 let mut cells = vec![
8157 StructuredCell {
8158 row: 0,
8159 col: 0,
8160 rowspan: 1,
8161 colspan: 1,
8162 is_header: false,
8163 text: String::new(),
8164 page_pt_bbox: [10.0, 546.0, 200.0, 560.0],
8165 },
8166 StructuredCell {
8167 row: 1,
8168 col: 0,
8169 rowspan: 1,
8170 colspan: 1,
8171 is_header: false,
8172 text: String::new(),
8173 page_pt_bbox: [10.0, 566.0, 200.0, 580.0],
8174 },
8175 StructuredCell {
8176 row: 2,
8177 col: 0,
8178 rowspan: 1,
8179 colspan: 1,
8180 is_header: false,
8181 text: String::new(),
8182 page_pt_bbox: [10.0, 586.0, 200.0, 600.0],
8183 },
8184 ];
8185 let page_h = 800.0;
8186
8187 let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8188 for cell in &mut cells {
8189 let [x1, y1, x2, y2] = cell.page_pt_bbox;
8190 let bounds = region_bounds(x1, y1, x2, y2, page_h, RegionCoordSpace::Standard);
8191 let mut matched: Vec<TextItem> = Vec::new();
8192 for (i, item) in items.iter().enumerate() {
8193 if tsr_region_contains_item(item, bounds) {
8194 claimed.insert(i);
8195 matched.push(item.clone());
8196 }
8197 }
8198 cell.text = collect_text_from_matched_items(matched, 0.10).replace(['\n', '\r'], " ");
8199 }
8200 for c in &cells {
8202 assert!(
8203 c.text.is_empty(),
8204 "stage 1 should leave all cells empty: {:?}",
8205 c
8206 );
8207 }
8208
8209 tsr_assign_orphan_items(
8210 &items,
8211 &mut cells,
8212 &claimed,
8213 page_h,
8214 RegionCoordSpace::Standard,
8215 );
8216 assert_eq!(cells[0].text, "Bellevue");
8217 assert_eq!(cells[1].text, "Glenwood");
8218 assert_eq!(cells[2].text, "Metro Crossing");
8219 }
8220
8221 #[test]
8222 fn stage2_rejects_cross_line_stacking_into_same_cell() {
8223 use crate::tables::StructuredCell;
8229 let mut cells = vec![
8233 StructuredCell {
8234 row: 0,
8235 col: 0,
8236 rowspan: 1,
8237 colspan: 1,
8238 is_header: false,
8239 text: String::new(),
8240 page_pt_bbox: [10.0, 100.0, 100.0, 110.0],
8241 },
8242 StructuredCell {
8243 row: 1,
8244 col: 0,
8245 rowspan: 1,
8246 colspan: 1,
8247 is_header: false,
8248 text: String::new(),
8249 page_pt_bbox: [10.0, 112.0, 100.0, 122.0],
8250 },
8251 ];
8252 let items = vec![
8261 test_item("Shawnee", 30.0, 94.0, 50.0, 8.0),
8263 test_item("BlueValley", 30.0, 80.0, 60.0, 8.0),
8265 ];
8266 let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8267
8268 tsr_assign_orphan_items(
8269 &items,
8270 &mut cells,
8271 &claimed,
8272 200.0,
8273 RegionCoordSpace::Standard,
8274 );
8275 assert_eq!(cells[0].text, "Shawnee");
8276 assert_eq!(cells[1].text, "BlueValley");
8277 assert!(!cells[0].text.contains("BlueValley"));
8278 assert!(!cells[1].text.contains("Shawnee"));
8279 }
8280
8281 #[test]
8282 fn stage2_allows_same_line_orphans_to_stack_into_one_cell() {
8283 use crate::tables::StructuredCell;
8288 let mut cells = vec![StructuredCell {
8289 row: 0,
8290 col: 0,
8291 rowspan: 1,
8292 colspan: 1,
8293 is_header: false,
8294 text: String::new(),
8295 page_pt_bbox: [10.0, 100.0, 200.0, 110.0],
8296 }];
8297 let items = vec![
8299 test_item("Blue", 30.0, 94.0, 25.0, 8.0),
8300 test_item("Valley", 60.0, 94.0, 35.0, 8.0),
8301 test_item("Parkway", 100.0, 94.0, 45.0, 8.0),
8302 ];
8303 let claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8304
8305 tsr_assign_orphan_items(
8306 &items,
8307 &mut cells,
8308 &claimed,
8309 200.0,
8310 RegionCoordSpace::Standard,
8311 );
8312 assert_eq!(cells[0].text, "Blue Valley Parkway");
8314 }
8315
8316 #[test]
8317 fn stage2_does_not_overwrite_filled_cells_or_admit_far_orphans() {
8318 use crate::tables::StructuredCell;
8322 let items = vec![
8323 test_item("Real", 50.0, 100.0, 30.0, 8.0),
8324 test_item("FigureTitle", 50.0, 20.0, 60.0, 8.0),
8329 ];
8330 let mut cells = vec![
8331 StructuredCell {
8332 row: 0,
8333 col: 0,
8334 rowspan: 1,
8335 colspan: 1,
8336 is_header: false,
8337 text: String::new(),
8338 page_pt_bbox: [40.0, 96.0, 100.0, 108.0],
8339 },
8340 StructuredCell {
8341 row: 1,
8342 col: 0,
8343 rowspan: 1,
8344 colspan: 1,
8345 is_header: false,
8346 text: "Pre-filled".to_string(),
8347 page_pt_bbox: [40.0, 116.0, 100.0, 128.0],
8348 },
8349 ];
8350 let mut claimed: std::collections::HashSet<usize> = std::collections::HashSet::new();
8351 claimed.insert(0);
8354
8355 tsr_assign_orphan_items(
8356 &items,
8357 &mut cells,
8358 &claimed,
8359 200.0,
8360 RegionCoordSpace::Standard,
8361 );
8362 assert_eq!(cells[0].text, "");
8364 assert_eq!(cells[1].text, "Pre-filled");
8366 }
8367
8368 #[test]
8378 fn find_xref_rejects_standalone_token_without_subsection_header() {
8379 let buf = b"Please refer to the xref appendix for details.";
8382 assert_eq!(find_last_valid_xref_table_start(buf), None);
8383 }
8384
8385 #[test]
8386 fn find_xref_accepts_real_classic_table_header() {
8387 let buf = b"garbage\nxref\n0 6\n0000000000 65535 f \n%%EOF";
8388 let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
8389 assert_eq!(&buf[pos..pos + 4], b"xref");
8390 assert_eq!(&buf[pos..], b"xref\n0 6\n0000000000 65535 f \n%%EOF");
8391 }
8392
8393 #[test]
8394 fn find_xref_skips_coincidental_match_and_finds_real_table_before_it() {
8395 let buf = b"xref\n0 3\n0000000000 65535 f \ntrailer\nsee the xref\n";
8400 let pos = find_last_valid_xref_table_start(buf).expect("should find the real table");
8401 assert_eq!(pos, 0);
8402 }
8403
8404 #[test]
8405 fn find_xref_rejects_substring_of_startxref() {
8406 let buf = b"startxref\n1234\n%%EOF";
8410 assert_eq!(find_last_valid_xref_table_start(buf), None);
8411 }
8412
8413 #[test]
8414 fn find_xref_rejects_count_run_with_trailing_garbage() {
8415 let buf = b"xref\n0 6garbage\n%%EOF";
8420 assert_eq!(find_last_valid_xref_table_start(buf), None);
8421 }
8422
8423 #[test]
8424 fn recover_startxref_pointer_returns_none_without_a_valid_table() {
8425 let buf = b"Please refer to the xref appendix for details.";
8426 assert!(recover_startxref_pointer(buf).is_none());
8427 }
8428}
8429
8430#[cfg(test)]
8431mod rotated_run_region_tests {
8432 use super::*;
8433 use crate::types::ItemType;
8434
8435 fn item(text: &str, x: f32, y: f32, width: f32, height: f32, rotation: f32) -> TextItem {
8436 TextItem {
8437 baseline_shift: 0.0,
8438 text: text.to_string(),
8439 x,
8440 y,
8441 width,
8442 height,
8443 rotation,
8444 advance_known: true,
8445 font: "Helvetica".to_string(),
8446 font_tag: "F1".to_string(),
8447 legacy_symbol_rewrite: false,
8448 font_size: if rotation == 0.0 { height } else { width },
8449 page: 1,
8450 is_bold: false,
8451 is_italic: false,
8452 font_weight: None,
8453 bold_source: None,
8454 fixed_pitch: None,
8455 fill_color: None,
8456 stroke_color: None,
8457 render_mode: None,
8458 is_underline: false,
8459 is_strikeout: false,
8460 item_type: ItemType::Text,
8461 mcid: None,
8462 }
8463 }
8464
8465 const STAMP: &str = "arXiv:2301.00001v1 [cs.CL] 1 Jan 2023";
8466
8467 #[test]
8468 fn vertical_margin_run_overlaps_only_the_margin_region() {
8469 let page_h = 792.0;
8472 let margin = region_bounds(0.0, 0.0, 50.0, 792.0, page_h, RegionCoordSpace::Standard);
8473 let body = region_bounds(60.0, 0.0, 612.0, 792.0, page_h, RegionCoordSpace::Standard);
8474
8475 let stamp = item(STAMP, 12.0, 200.0, 20.0, 400.0, 90.0);
8476 assert!(region_overlaps_item(&stamp, margin));
8477 assert!(!region_overlaps_item(&stamp, body));
8478 assert!(region_item_overlap_area(&stamp, margin) > 0.0);
8479 assert_eq!(region_item_overlap_area(&stamp, body), 0.0);
8480 }
8481
8482 #[test]
8483 fn collect_text_in_region_finds_the_vertical_run_by_its_box() {
8484 let items = vec![
8485 item(STAMP, 12.0, 200.0, 20.0, 400.0, 90.0),
8486 item("Body text", 72.0, 500.0, 50.0, 11.0, 0.0),
8487 ];
8488 assert_eq!(
8489 collect_text_in_region(&items, 0.0, 0.0, 50.0, 792.0, 792.0),
8490 STAMP
8491 );
8492 assert_eq!(
8493 collect_text_in_region(&items, 60.0, 0.0, 612.0, 792.0, 792.0),
8494 "Body text"
8495 );
8496 }
8497
8498 #[test]
8499 fn clockwise_frame_region_bounds_follow_the_turned_page() {
8500 let page_h = 792.0;
8504 let header = item("HEADER", -700.0, 580.0, 36.0, 10.0, 0.0);
8505 let strip = region_bounds(
8507 570.0,
8508 80.0,
8509 600.0,
8510 140.0,
8511 page_h,
8512 RegionCoordSpace::Rotated90Cw,
8513 );
8514 let body = region_bounds(
8515 60.0,
8516 80.0,
8517 500.0,
8518 700.0,
8519 page_h,
8520 RegionCoordSpace::Rotated90Cw,
8521 );
8522 assert!(region_overlaps_item(&header, strip));
8523 assert!(!region_overlaps_item(&header, body));
8524
8525 assert_eq!(
8529 infer_region_coord_space(&[header.clone()]),
8530 RegionCoordSpace::Standard
8531 );
8532 assert_eq!(
8533 infer_region_coord_space(&[item("x", 100.0, -200.0, 36.0, 12.0, 0.0)]),
8534 RegionCoordSpace::Rotated90Ccw
8535 );
8536 assert_eq!(
8537 infer_region_coord_space(&[item("x", 100.0, 200.0, 36.0, 12.0, 0.0)]),
8538 RegionCoordSpace::Standard
8539 );
8540 let strip = collect_text_in_region_in_frame(
8541 &[header.clone()],
8542 570.0,
8543 80.0,
8544 600.0,
8545 140.0,
8546 page_h,
8547 PageRotation::Cw,
8548 );
8549 assert_eq!(strip, "HEADER");
8550 let misread = collect_text_in_region_in_frame(
8551 &[header.clone()],
8552 570.0,
8553 80.0,
8554 600.0,
8555 140.0,
8556 page_h,
8557 PageRotation::Upright,
8558 );
8559 assert_eq!(misread, "");
8560
8561 for coords in [
8563 RegionCoordSpace::Standard,
8564 RegionCoordSpace::Rotated90Ccw,
8565 RegionCoordSpace::Rotated90Cw,
8566 ] {
8567 let b = region_bounds(570.0, 80.0, 600.0, 140.0, page_h, coords);
8568 let back = extracted_bbox_to_page_top_left(
8569 [b.x_min, b.y_min, b.x_max, b.y_max],
8570 page_h,
8571 coords,
8572 );
8573 assert_eq!(back, [570.0, 80.0, 600.0, 140.0], "{coords:?}");
8574 }
8575 }
8576
8577 #[test]
8578 fn tsr_line_clustering_uses_the_estimated_height_of_unknown_advances() {
8579 let estimated = STAMP.chars().count() as f32 * 0.5 * 20.0;
8580 let mut stamp = item(STAMP, 12.0, 200.0, 20.0, estimated, 90.0);
8581 stamp.advance_known = false;
8582 let lines = cluster_tsr_cell_text_lines(vec![stamp]);
8583 assert_eq!(lines.len(), 1);
8584 assert!(
8586 (lines[0].half_height - estimated * 0.5).abs() < 1e-3,
8587 "{}",
8588 lines[0].half_height
8589 );
8590 }
8591
8592 #[test]
8593 fn vertical_run_without_font_widths_still_overlaps_its_region() {
8594 let page_h = 792.0;
8598 let margin = region_bounds(0.0, 0.0, 50.0, 792.0, page_h, RegionCoordSpace::Standard);
8599 let estimated = STAMP.chars().count() as f32 * 0.5 * 20.0;
8603 let mut stamp = item(STAMP, 12.0, 200.0, 20.0, estimated, 90.0);
8604 stamp.advance_known = false;
8605 assert!(region_overlaps_item(&stamp, margin));
8606 assert!(region_item_overlap_area(&stamp, margin) > 0.0);
8607 assert!(tsr_region_contains_item(&stamp, margin));
8608 }
8609
8610 #[test]
8611 fn token_splitting_leaves_rotated_runs_whole() {
8612 let stamp = item("two words", 12.0, 200.0, 20.0, 100.0, 90.0);
8613 let tokens = split_item_into_token_subitems(&stamp);
8614 assert_eq!(tokens.len(), 1);
8615 assert_eq!(tokens[0].text, "two words");
8616 assert_eq!((tokens[0].x, tokens[0].width), (12.0, 20.0));
8617
8618 let flipped = item("two words", 72.0, 500.0, 90.0, 10.0, 180.0);
8621 assert_eq!(split_item_into_token_subitems(&flipped).len(), 1);
8622
8623 let body = item("two words", 72.0, 500.0, 90.0, 10.0, 0.0);
8624 assert_eq!(split_item_into_token_subitems(&body).len(), 2);
8625 }
8626
8627 #[test]
8628 fn token_splitting_spreads_only_an_unknown_zero_width_run() {
8629 let zero = item("two words", 72.0, 500.0, 0.0, 10.0, 0.0);
8633 let tokens = split_item_into_token_subitems(&zero);
8634 assert_eq!(tokens.len(), 2);
8635 assert_eq!((tokens[0].x, tokens[1].x), (72.0, 72.0));
8636
8637 let mut unknown = zero;
8638 unknown.advance_known = false;
8639 let tokens = split_item_into_token_subitems(&unknown);
8640 assert_eq!(tokens.len(), 2);
8641 assert!(tokens[1].x > tokens[0].x + 15.0, "{tokens:?}");
8642 }
8643}