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