1#![warn(missing_docs)]
8
9pub mod api;
10pub mod models;
11pub mod output;
12pub mod pdf;
13pub mod pipeline;
14pub mod utils;
15
16pub mod hybrid;
18
19pub mod tagged;
20
21use crate::api::config::ProcessingConfig;
22use crate::models::content::ContentElement;
23use crate::models::document::PdfDocument;
24use crate::pdf::bookmark_extractor::extract_bookmarks;
25use crate::pdf::chunk_parser::extract_page_chunks;
26use crate::pdf::page_info;
27#[cfg(not(target_arch = "wasm32"))]
28use crate::pdf::raster_table_ocr::{
29 recover_dominant_image_text_chunks, recover_page_raster_table_cell_text,
30 recover_raster_table_borders,
31};
32use crate::pipeline::orchestrator::{run_pipeline, PipelineState};
33use crate::pipeline::stages::heading_detector::refine_heading_hierarchy;
34use crate::tagged::struct_tree::{build_mcid_map, should_use_struct_tree};
35use std::time::Instant;
36
37#[cfg(not(target_arch = "wasm32"))]
41fn recover_page_image_content(
42 input_path: &std::path::Path,
43 config: &ProcessingConfig,
44 page_info_by_number: &[Option<&page_info::PageInfo>],
45 page_num: u32,
46 page_chunks: &crate::pdf::chunk_parser::PageChunks,
47 ocr_budget: &mut crate::pdf::image_region::OcrBudget,
48) -> (
49 Vec<crate::models::chunks::TextChunk>,
50 Vec<crate::models::table::TableBorder>,
51) {
52 if !config.raster_table_ocr_enabled() || ocr_budget.exhausted() {
53 return (Vec::new(), Vec::new());
54 }
55 let Some(Some(page_info)) = page_info_by_number.get(page_num as usize) else {
56 return (Vec::new(), Vec::new());
57 };
58 let text = recover_dominant_image_text_chunks(
59 input_path,
60 &page_info.crop_box,
61 page_num,
62 &page_chunks.text_chunks,
63 &page_chunks.image_chunks,
64 );
65 if ocr_budget.exhausted() {
66 return (text, Vec::new());
67 }
68 let tables = recover_raster_table_borders(
69 input_path,
70 &page_info.crop_box,
71 page_num,
72 &page_chunks.text_chunks,
73 &page_chunks.image_chunks,
74 );
75 (text, tables)
76}
77
78#[cfg(not(target_arch = "wasm32"))]
90pub fn convert(
91 input_path: &std::path::Path,
92 config: &ProcessingConfig,
93) -> Result<PdfDocument, EdgePdfError> {
94 let timing_enabled = timing_enabled();
95 let total_start = Instant::now();
96
97 let phase_start = Instant::now();
98 let raw_doc = pdf::loader::load_pdf(input_path, config.password.as_deref())?;
99 log_phase_duration(timing_enabled, "load_pdf", phase_start);
100
101 let phase_start = Instant::now();
103 let page_info_list = page_info::extract_page_info(&raw_doc.document);
104 log_phase_duration(timing_enabled, "extract_page_info", phase_start);
105
106 let pages_map = raw_doc.document.get_pages();
108 let page_info_by_number: Vec<Option<&page_info::PageInfo>> =
111 if config.raster_table_ocr_enabled() {
112 let mut index = vec![None; pages_map.len().saturating_add(1)];
113 for info in &page_info_list {
114 if let Some(slot) = index.get_mut(info.page_number as usize) {
115 *slot = Some(info);
116 }
117 }
118 index
119 } else {
120 Vec::new()
121 };
122 let mut page_contents = Vec::with_capacity(pages_map.len());
123
124 let phase_start = Instant::now();
125 let mut ocr_budget = config.ocr_budget();
126 for (&page_num, &page_id) in &pages_map {
127 let page_chunks = extract_page_chunks(&raw_doc.document, page_num, page_id)?;
128 let (recovered_text_chunks, recovered_tables) = recover_page_image_content(
129 input_path,
130 config,
131 &page_info_by_number,
132 page_num,
133 &page_chunks,
134 &mut ocr_budget,
135 );
136 let mut elements: Vec<ContentElement> = page_chunks
137 .text_chunks
138 .into_iter()
139 .map(ContentElement::TextChunk)
140 .collect();
141 elements.extend(
142 recovered_text_chunks
143 .into_iter()
144 .map(ContentElement::TextChunk),
145 );
146
147 elements.extend(
148 page_chunks
149 .image_chunks
150 .into_iter()
151 .map(ContentElement::Image),
152 );
153 elements.extend(
154 page_chunks
155 .line_chunks
156 .into_iter()
157 .map(ContentElement::Line),
158 );
159 elements.extend(
160 page_chunks
161 .line_art_chunks
162 .into_iter()
163 .map(ContentElement::LineArt),
164 );
165 elements.extend(
166 recovered_tables
167 .into_iter()
168 .map(ContentElement::TableBorder),
169 );
170
171 page_contents.push(elements);
172 }
173 log_phase_duration(timing_enabled, "extract_page_chunks", phase_start);
174
175 let phase_start = Instant::now();
176 let mcid_map = if should_use_struct_tree(&raw_doc.document, config.use_struct_tree) {
177 build_mcid_map(&raw_doc.document)
178 } else {
179 Default::default()
180 };
181 let mut pipeline_state = PipelineState::with_mcid_map(page_contents, config.clone(), mcid_map)
182 .with_page_info(page_info_list);
183 run_pipeline(&mut pipeline_state)?;
184 let bookmarks = extract_bookmarks(&raw_doc.document);
186 if !bookmarks.is_empty() {
187 refine_heading_hierarchy(&mut pipeline_state.pages, &bookmarks);
188 } else {
189 refine_heading_hierarchy(&mut pipeline_state.pages, &[]);
190 }
191 log_phase_duration(timing_enabled, "run_pipeline", phase_start);
192
193 let file_name = input_path
195 .file_name()
196 .and_then(|n| n.to_str())
197 .unwrap_or("unknown.pdf")
198 .to_string();
199
200 let mut doc = PdfDocument::new(file_name);
201 doc.source_path = Some(input_path.display().to_string());
202 doc.number_of_pages = pages_map.len() as u32;
203 doc.author = raw_doc.metadata.author;
204 doc.title = raw_doc.metadata.title;
205 doc.creation_date = raw_doc.metadata.creation_date;
206 doc.modification_date = raw_doc.metadata.modification_date;
207
208 let phase_start = Instant::now();
209 if config.raster_table_ocr_enabled() {
210 for (page_idx, page) in pipeline_state.pages.iter_mut().enumerate() {
211 if let Some(page_info) = pipeline_state.page_info.get(page_idx) {
212 recover_page_raster_table_cell_text(
213 input_path,
214 &page_info.crop_box,
215 page_info.page_number,
216 page,
217 );
218 }
219 }
220 }
221 log_phase_duration(
222 timing_enabled,
223 "recover_page_raster_table_cell_text",
224 phase_start,
225 );
226
227 let phase_start = Instant::now();
229 for page in pipeline_state.pages {
230 doc.kids.extend(page);
231 }
232 log_phase_duration(timing_enabled, "flatten_document", phase_start);
233 log_phase_duration(timing_enabled, "convert_total", total_start);
234
235 Ok(doc)
236}
237
238pub fn convert_bytes(
259 data: &[u8],
260 file_name: &str,
261 config: &ProcessingConfig,
262) -> Result<PdfDocument, EdgePdfError> {
263 let session = extract_session(data, file_name, config, None)?;
264 assemble(session, OcrAssembleMode::SyncEngine)
265}
266
267pub type ProgressFn<'a> = dyn FnMut(&str, u32, u32) + 'a;
269
270pub struct ExtractSession {
272 pub file_name: String,
274 pub config: ProcessingConfig,
276 pub author: Option<String>,
278 pub title: Option<String>,
280 pub creation_date: Option<String>,
282 pub modification_date: Option<String>,
284 pub producer: Option<String>,
286 pub creator: Option<String>,
288 pub number_of_pages: u32,
290 pub page_contents: Vec<Vec<ContentElement>>,
292 pub page_info_list: Vec<page_info::PageInfo>,
294 pub mcid_map: crate::tagged::struct_tree::McidMap,
296 pub bookmarks: Vec<pdf::bookmark_extractor::Bookmark>,
298 #[cfg(feature = "image")]
300 pub candidates: Vec<pdf::inmem_raster::RasterCandidate>,
301 #[cfg(feature = "image")]
303 pub ocr_words: std::collections::HashMap<u32, Vec<pdf::ocr::OcrWord>>,
304}
305
306pub enum OcrAssembleMode {
308 SyncEngine,
310 Provided,
312 #[cfg(feature = "image")]
314 Engine(std::sync::Arc<dyn pdf::ocr::OcrEngine>),
315}
316
317pub fn extract_session(
321 data: &[u8],
322 file_name: &str,
323 config: &ProcessingConfig,
324 mut on_progress: Option<&mut ProgressFn<'_>>,
325) -> Result<ExtractSession, EdgePdfError> {
326 let raw_doc = pdf::loader::load_pdf_from_bytes(data, config.password.as_deref())?;
327 let page_info_list = page_info::extract_page_info(&raw_doc.document);
328 let pages_map = raw_doc.document.get_pages();
329 let page_total = pages_map.len() as u32;
330 let mut page_contents = Vec::with_capacity(pages_map.len());
331 #[cfg(feature = "image")]
332 let mut candidates = Vec::new();
333 #[cfg(feature = "image")]
334 let mut next_id = 0u32;
335
336 let mut page_done = 0u32;
337 for (&page_num, &page_id) in &pages_map {
338 let page_chunks = extract_page_chunks(&raw_doc.document, page_num, page_id)?;
339
340 #[cfg(feature = "image")]
341 if config.raster_table_ocr_enabled() {
342 let (page_w, page_h) = page_info_list
343 .iter()
344 .find(|p| p.page_number == page_num)
345 .map(|p| (p.width, p.height))
346 .unwrap_or((612.0, 792.0));
347 let page_cands = pdf::inmem_raster::collect_raster_candidates(
348 &raw_doc.document,
349 page_id,
350 &page_chunks.image_chunks,
351 &page_chunks.text_chunks,
352 page_w,
353 page_h,
354 &mut next_id,
355 );
356 candidates.extend(page_cands);
357 }
358
359 let mut elements: Vec<ContentElement> = page_chunks
360 .text_chunks
361 .into_iter()
362 .map(ContentElement::TextChunk)
363 .collect();
364 elements.extend(
365 page_chunks
366 .image_chunks
367 .into_iter()
368 .map(ContentElement::Image),
369 );
370 elements.extend(
371 page_chunks
372 .line_chunks
373 .into_iter()
374 .map(ContentElement::Line),
375 );
376 elements.extend(
377 page_chunks
378 .line_art_chunks
379 .into_iter()
380 .map(ContentElement::LineArt),
381 );
382 page_contents.push(elements);
384
385 page_done += 1;
386 if let Some(cb) = on_progress.as_mut() {
387 cb("planning", page_done, page_total);
388 }
389 }
390
391 let mcid_map = if should_use_struct_tree(&raw_doc.document, config.use_struct_tree) {
392 build_mcid_map(&raw_doc.document)
393 } else {
394 Default::default()
395 };
396 let bookmarks = extract_bookmarks(&raw_doc.document);
397
398 Ok(ExtractSession {
399 file_name: file_name.to_string(),
400 config: config.clone(),
401 author: raw_doc.metadata.author,
402 title: raw_doc.metadata.title,
403 creation_date: raw_doc.metadata.creation_date,
404 modification_date: raw_doc.metadata.modification_date,
405 producer: raw_doc.metadata.producer,
406 creator: raw_doc.metadata.creator,
407 number_of_pages: pages_map.len() as u32,
408 page_contents,
409 page_info_list,
410 mcid_map,
411 bookmarks,
412 #[cfg(feature = "image")]
413 candidates,
414 #[cfg(feature = "image")]
415 ocr_words: std::collections::HashMap::new(),
416 })
417}
418
419impl ExtractSession {
420 #[cfg(feature = "image")]
422 pub fn provide_ocr(&mut self, id: u32, words: Vec<pdf::ocr::OcrWord>) {
423 self.ocr_words.insert(id, words);
424 }
425
426 #[cfg(feature = "image")]
428 pub fn candidate_metas(&self) -> Vec<pdf::inmem_raster::RasterCandidateMeta> {
429 self.candidates.iter().map(|c| c.meta()).collect()
430 }
431
432 #[cfg(feature = "image")]
434 pub fn candidate_gray(&self, id: u32) -> Option<&[u8]> {
435 self.candidates
436 .iter()
437 .find(|c| c.id == id)
438 .map(|c| c.gray.as_slice())
439 }
440}
441
442#[cfg(feature = "image")]
444fn inject_ocr_text_blocks(
445 session: &mut ExtractSession,
446 page_idx: usize,
447 cand: &pdf::inmem_raster::RasterCandidate,
448 words: &[pdf::ocr::OcrWord],
449) {
450 if let Some(page) = session.page_contents.get_mut(page_idx) {
451 if let Some(idx) = cand.image_index {
452 page.retain(|el| match el {
453 ContentElement::Image(img) => img.index != Some(idx),
454 _ => true,
455 });
456 }
457 for chunk in pdf::inmem_raster::ocr_words_to_text_chunks(cand, words) {
458 page.push(ContentElement::TextChunk(chunk));
459 }
460 }
461}
462
463pub fn assemble(
465 mut session: ExtractSession,
466 mode: OcrAssembleMode,
467) -> Result<PdfDocument, EdgePdfError> {
468 #[cfg(feature = "image")]
469 if session.config.raster_table_ocr_enabled() {
470 use image::GrayImage;
471 use pdf::inmem_raster::RasterCandidateKind;
472 use pdf::ocr::{default_engine, OcrEngine};
473
474 let candidates = std::mem::take(&mut session.candidates);
475 for cand in &candidates {
476 let words = match &mode {
477 OcrAssembleMode::SyncEngine => {
478 if let Some(gray) =
479 GrayImage::from_raw(cand.width, cand.height, cand.gray.clone())
480 {
481 OcrEngine::recognize(&*default_engine(), &gray)
482 } else {
483 Vec::new()
484 }
485 }
486 OcrAssembleMode::Provided => {
487 session.ocr_words.get(&cand.id).cloned().unwrap_or_default()
488 }
489 OcrAssembleMode::Engine(engine) => {
490 if let Some(gray) =
491 GrayImage::from_raw(cand.width, cand.height, cand.gray.clone())
492 {
493 engine.recognize(&gray)
494 } else {
495 Vec::new()
496 }
497 }
498 };
499 let page_idx = cand.page.saturating_sub(1) as usize;
500 match cand.kind {
501 RasterCandidateKind::Table => {
502 let table = pdf::inmem_raster::build_table_from_candidate(cand, &words)
503 .filter(pdf::inmem_raster::bordered_table_is_plausible);
504 if let Some(table) = table {
505 if let Some(page) = session.page_contents.get_mut(page_idx) {
506 page.push(ContentElement::TableBorder(table));
507 }
508 } else if !words.is_empty() {
509 inject_ocr_text_blocks(&mut session, page_idx, cand, &words);
512 }
513 }
514 RasterCandidateKind::TextBlocks => {
515 inject_ocr_text_blocks(&mut session, page_idx, cand, &words);
516 }
517 }
518 }
519 session.candidates = candidates;
520 }
521
522 #[cfg(not(feature = "image"))]
523 let _ = mode;
524
525 let mut pipeline_state = PipelineState::with_mcid_map(
526 session.page_contents,
527 session.config.clone(),
528 session.mcid_map,
529 )
530 .with_page_info(session.page_info_list);
531 run_pipeline(&mut pipeline_state)?;
532 refine_heading_hierarchy(&mut pipeline_state.pages, &session.bookmarks);
533
534 let mut doc = PdfDocument::new(session.file_name);
535 doc.number_of_pages = session.number_of_pages;
536 doc.author = session.author;
537 doc.title = session.title;
538 doc.creation_date = session.creation_date;
539 doc.modification_date = session.modification_date;
540 doc.producer = session.producer;
541 doc.creator = session.creator;
542
543 for page in pipeline_state.pages {
544 doc.kids.extend(page);
545 }
546
547 Ok(doc)
548}
549
550#[derive(Debug, thiserror::Error)]
552pub enum EdgePdfError {
553 #[error("PDF loading error: {0}")]
555 LoadError(String),
556
557 #[error("Pipeline error at stage {stage}: {message}")]
559 PipelineError {
560 stage: u32,
562 message: String,
564 },
565
566 #[error("Output error: {0}")]
568 OutputError(String),
569
570 #[error("I/O error: {0}")]
572 IoError(#[from] std::io::Error),
573
574 #[error("Configuration error: {0}")]
576 ConfigError(String),
577
578 #[error("PDF parse error: {0}")]
580 LopdfError(String),
581}
582
583impl From<lopdf::Error> for EdgePdfError {
584 fn from(e: lopdf::Error) -> Self {
585 EdgePdfError::LopdfError(e.to_string())
586 }
587}
588
589fn timing_enabled() -> bool {
590 std::env::var("EDGEPARSE_TIMING")
591 .map(|value| {
592 matches!(
593 value.to_ascii_lowercase().as_str(),
594 "1" | "true" | "yes" | "on"
595 )
596 })
597 .unwrap_or(false)
598}
599
600fn log_phase_duration(enabled: bool, phase: &str, start: Instant) {
601 if enabled {
602 log::info!(
603 "Timing {}: {:.2} ms",
604 phase,
605 start.elapsed().as_secs_f64() * 1000.0
606 );
607 }
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use lopdf::{
614 content::{Content, Operation},
615 dictionary, Object, Stream,
616 };
617 use std::io::Write;
618
619 fn create_test_pdf_file(path: &std::path::Path) {
621 let mut doc = lopdf::Document::with_version("1.5");
622 let pages_id = doc.new_object_id();
623
624 let font_id = doc.add_object(dictionary! {
625 "Type" => "Font",
626 "Subtype" => "Type1",
627 "BaseFont" => "Helvetica",
628 });
629
630 let resources_id = doc.add_object(dictionary! {
631 "Font" => dictionary! {
632 "F1" => font_id,
633 },
634 });
635
636 let content = Content {
637 operations: vec![
638 Operation::new("BT", vec![]),
639 Operation::new("Tf", vec!["F1".into(), 12.into()]),
640 Operation::new("Td", vec![72.into(), 700.into()]),
641 Operation::new("Tj", vec![Object::string_literal("Hello EdgeParse!")]),
642 Operation::new("Td", vec![0.into(), Object::Real(-20.0)]),
643 Operation::new("Tj", vec![Object::string_literal("Second line of text.")]),
644 Operation::new("ET", vec![]),
645 ],
646 };
647
648 let encoded = content.encode().unwrap();
649 let content_id = doc.add_object(Stream::new(dictionary! {}, encoded));
650
651 let page_id = doc.add_object(dictionary! {
652 "Type" => "Page",
653 "Parent" => pages_id,
654 "Contents" => content_id,
655 "Resources" => resources_id,
656 "MediaBox" => vec![0.into(), 0.into(), 595.into(), 842.into()],
657 });
658
659 let pages = dictionary! {
660 "Type" => "Pages",
661 "Kids" => vec![page_id.into()],
662 "Count" => 1,
663 };
664 doc.objects.insert(pages_id, Object::Dictionary(pages));
665
666 let catalog_id = doc.add_object(dictionary! {
667 "Type" => "Catalog",
668 "Pages" => pages_id,
669 });
670 doc.trailer.set("Root", catalog_id);
671
672 let mut file = std::fs::File::create(path).unwrap();
673 doc.save_to(&mut file).unwrap();
674 file.flush().unwrap();
675 }
676
677 #[test]
678 fn test_convert_end_to_end() {
679 let dir = std::env::temp_dir().join("edgeparse_test");
680 std::fs::create_dir_all(&dir).unwrap();
681 let pdf_path = dir.join("test_convert.pdf");
682
683 create_test_pdf_file(&pdf_path);
684
685 let config = ProcessingConfig::default();
686 let result = convert(&pdf_path, &config);
687 assert!(result.is_ok(), "convert() failed: {:?}", result.err());
688
689 let doc = result.unwrap();
690 assert_eq!(doc.number_of_pages, 1);
691 assert!(
692 !doc.kids.is_empty(),
693 "Expected content elements in document"
694 );
695
696 let mut all_text = String::new();
698 for element in &doc.kids {
699 match element {
700 models::content::ContentElement::TextChunk(tc) => {
701 all_text.push_str(&tc.value);
702 all_text.push(' ');
703 }
704 models::content::ContentElement::TextLine(tl) => {
705 all_text.push_str(&tl.value());
706 all_text.push(' ');
707 }
708 models::content::ContentElement::TextBlock(tb) => {
709 all_text.push_str(&tb.value());
710 all_text.push(' ');
711 }
712 models::content::ContentElement::Paragraph(p) => {
713 all_text.push_str(&p.base.value());
714 all_text.push(' ');
715 }
716 models::content::ContentElement::Heading(h) => {
717 all_text.push_str(&h.base.base.value());
718 all_text.push(' ');
719 }
720 _ => {}
721 }
722 }
723
724 assert!(
725 all_text.contains("Hello"),
726 "Expected 'Hello' in extracted text, got: {}",
727 all_text
728 );
729 assert!(
730 all_text.contains("Second"),
731 "Expected 'Second' in extracted text, got: {}",
732 all_text
733 );
734
735 let _ = std::fs::remove_file(&pdf_path);
737 }
738
739 #[cfg(feature = "image")]
740 struct FakeOcrEngine;
741
742 #[cfg(feature = "image")]
743 impl pdf::ocr::OcrEngine for FakeOcrEngine {
744 fn recognize(&self, _gray: &image::GrayImage) -> Vec<pdf::ocr::OcrWord> {
745 vec![
746 pdf::ocr::OcrWord {
747 line_key: (1, 1, 1),
748 left: 10,
749 top: 10,
750 width: 40,
751 height: 12,
752 text: "A".into(),
753 confidence: 95.0,
754 },
755 pdf::ocr::OcrWord {
756 line_key: (1, 1, 1),
757 left: 80,
758 top: 10,
759 width: 40,
760 height: 12,
761 text: "B".into(),
762 confidence: 95.0,
763 },
764 pdf::ocr::OcrWord {
765 line_key: (1, 1, 2),
766 left: 10,
767 top: 40,
768 width: 40,
769 height: 12,
770 text: "C".into(),
771 confidence: 95.0,
772 },
773 pdf::ocr::OcrWord {
774 line_key: (1, 1, 2),
775 left: 80,
776 top: 40,
777 width: 40,
778 height: 12,
779 text: "D".into(),
780 confidence: 95.0,
781 },
782 ]
783 }
784 }
785
786 #[cfg(feature = "image")]
787 fn count_table_borders(doc: &PdfDocument) -> usize {
788 doc.kids
789 .iter()
790 .filter(|e| matches!(e, models::content::ContentElement::TableBorder(_)))
791 .count()
792 }
793
794 #[cfg(feature = "image")]
796 #[test]
797 fn two_phase_equals_sync_fake_engine() {
798 let dir = std::env::temp_dir().join("edgeparse_two_phase");
799 std::fs::create_dir_all(&dir).unwrap();
800 let pdf_path = dir.join("hello.pdf");
801 create_test_pdf_file(&pdf_path);
802 let data = std::fs::read(&pdf_path).unwrap();
803 let config = ProcessingConfig::default();
804 let engine: std::sync::Arc<dyn pdf::ocr::OcrEngine> = std::sync::Arc::new(FakeOcrEngine);
805
806 let sync_doc = {
807 let session = extract_session(&data, "hello.pdf", &config, None).unwrap();
808 assemble(session, OcrAssembleMode::Engine(engine.clone())).unwrap()
809 };
810
811 let two_phase_doc = {
812 let mut session = extract_session(&data, "hello.pdf", &config, None).unwrap();
813 let ids: Vec<_> = session.candidates.iter().map(|c| c.id).collect();
814 for id in ids {
815 let gray = session.candidate_gray(id).unwrap().to_vec();
816 let meta = session.candidates.iter().find(|c| c.id == id).unwrap();
817 let img = image::GrayImage::from_raw(meta.width, meta.height, gray).unwrap();
818 let words = engine.recognize(&img);
819 session.provide_ocr(id, words);
820 }
821 assemble(session, OcrAssembleMode::Provided).unwrap()
822 };
823
824 assert_eq!(sync_doc.number_of_pages, two_phase_doc.number_of_pages);
825 assert_eq!(
826 count_table_borders(&sync_doc),
827 count_table_borders(&two_phase_doc)
828 );
829 let sync_md = output::markdown::to_markdown(&sync_doc).unwrap();
830 let phase_md = output::markdown::to_markdown(&two_phase_doc).unwrap();
831 assert_eq!(sync_md, phase_md);
832 let _ = std::fs::remove_file(&pdf_path);
833 }
834
835 #[cfg(feature = "image")]
837 #[test]
838 fn candidate_prefilter_keeps_docs_110_and_122() {
839 let roots = [
840 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../benchmark/pdfs"),
841 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../odl-bench/pdfs"),
842 ];
843 for id in ["01030000000110", "01030000000122"] {
844 let pdf = roots
845 .iter()
846 .map(|r| r.join(format!("{id}.pdf")))
847 .find(|p| p.exists());
848 let Some(pdf) = pdf else {
849 eprintln!("skip {id}: PDF not found");
850 continue;
851 };
852 let data = std::fs::read(&pdf).unwrap();
853 let config = ProcessingConfig::default();
854 let session = extract_session(&data, &format!("{id}.pdf"), &config, None).unwrap();
855 assert!(
856 !session.candidates.is_empty(),
857 "{id}: expected ≥1 OCR candidate, got 0 (pre-filter dropped tables)"
858 );
859 let doc = assemble(session, OcrAssembleMode::SyncEngine).unwrap();
861 let _ = count_table_borders(&doc);
864 }
865 }
866
867 #[cfg(feature = "image")]
870 #[test]
871 fn pattern_form_fixture_emits_text_blocks_candidate() {
872 let pdf =
873 std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/pattern_form.pdf");
874 assert!(pdf.exists(), "missing fixture {}", pdf.display());
875 let data = std::fs::read(&pdf).unwrap();
876 let config = ProcessingConfig::default();
877 let session = extract_session(&data, "pattern_form.pdf", &config, None).unwrap();
878 assert!(
879 !session.candidates.is_empty(),
880 "expected ≥1 OCR candidate from pattern-painted image"
881 );
882 assert!(
883 session
884 .candidates
885 .iter()
886 .any(|c| c.kind == pdf::inmem_raster::RasterCandidateKind::TextBlocks),
887 "expected TextBlocks candidate for page-sized raster"
888 );
889
890 let doc = assemble(session, OcrAssembleMode::Provided).unwrap();
892 assert_eq!(doc.producer.as_deref(), Some("Skia/PDF m151"));
893 let md = output::markdown::to_markdown(&doc).unwrap();
894 assert!(
895 !md.contains("# Penpot - Render"),
896 "artifact metadata title must not become H1, got:\n{md}"
897 );
898 assert!(
900 md.contains("NAME VALUE") || md.contains("ACME"),
901 "expected native text in markdown, got:\n{md}"
902 );
903 if let (Some(a), Some(b)) = (md.find("ACME"), md.find("NAME VALUE")) {
905 assert!(a < b, "expected ACME before NAME VALUE in:\n{md}");
906 }
907
908 let edge_lines = doc
910 .kids
911 .iter()
912 .filter(|e| {
913 matches!(
914 e,
915 models::content::ContentElement::Line(l)
916 if l.bbox.width() < 2.0 || l.bbox.height() < 2.0
917 )
918 })
919 .count();
920 assert_eq!(edge_lines, 0, "pattern fill must not emit page-edge lines");
921 assert!(
922 !doc.kids.iter().any(|e| matches!(
923 e,
924 models::content::ContentElement::Image(_)
925 | models::content::ContentElement::Figure(_)
926 )),
927 "page-sized TextBlocks source image should be dropped from layout"
928 );
929 }
930
931 #[test]
932 fn artifact_metadata_title_skipped_in_markdown() {
933 let mut doc = PdfDocument::new("x.pdf".into());
934 doc.title = Some("Penpot - Render".into());
935 doc.producer = Some("Skia/PDF m151".into());
936 doc.creator = Some("Chromium".into());
937 doc.number_of_pages = 1;
938 doc.kids.push(models::content::ContentElement::TextChunk(
940 models::chunks::TextChunk {
941 value: "MANSUY RAPHAEL".into(),
942 bbox: models::bbox::BoundingBox::new(Some(1), 10.0, 100.0, 200.0, 120.0),
943 font_name: "Helvetica".into(),
944 font_size: 12.0,
945 font_weight: 400.0,
946 italic_angle: 0.0,
947 font_color: "#000000".into(),
948 contrast_ratio: 21.0,
949 symbol_ends: vec![200.0],
950 text_format: models::enums::TextFormat::Normal,
951 text_type: models::enums::TextType::Regular,
952 pdf_layer: models::enums::PdfLayer::Main,
953 ocg_visible: true,
954 index: Some(0),
955 page_number: Some(1),
956 level: None,
957 mcid: None,
958 },
959 ));
960 let md = output::markdown::to_markdown(&doc).unwrap();
961 assert!(
962 !md.contains("# Penpot - Render"),
963 "uncorroborated metadata title must be skipped when body text exists, got:\n{md}"
964 );
965 assert!(md.contains("MANSUY RAPHAEL"), "got:\n{md}");
966 }
967}