easypdf_markdown/ocr/processor/
core.rs1use crate::render::{ImageFormat, RenderBackend, RenderConfig};
2use crate::{MarkdownProcessorCapabilities, MarkdownWarning, PdfMarkdownProcessor};
3use easypdf_core::PdfInput;
4use easypdf_core::{PdfBlock, PdfBlockType, PdfDocumentModel, PdfPageModel, SourceLocation};
5use easypdf_core::{PdfError, Result};
6
7use crate::ocr::config::{OcrConfig, OcrTrigger};
8use crate::ocr::engine::{OcrEngine, OcrImage};
9
10use super::renderer::{StoredRendererAdapter, render_error_to_pdf};
11
12pub struct OcrProcessor {
29 engine: Box<dyn OcrEngine>,
30 renderer: Option<Box<dyn crate::render::PdfRenderer>>,
31 backend: RenderBackend,
32 config: OcrConfig,
33}
34
35impl std::fmt::Debug for OcrProcessor {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 f.debug_struct("OcrProcessor")
38 .field("engine", &self.engine.name())
39 .field("config", &self.config)
40 .finish_non_exhaustive()
41 }
42}
43
44impl OcrProcessor {
45 #[must_use]
50 pub fn new(engine: Box<dyn OcrEngine>, backend: RenderBackend) -> Self {
51 Self {
52 engine,
53 renderer: None,
54 backend,
55 config: OcrConfig::default(),
56 }
57 }
58
59 #[must_use]
61 pub fn with_mock_engine() -> Self {
62 use crate::ocr::engines::MockOcrEngine;
63 Self::new(Box::new(MockOcrEngine::new()), RenderBackend::Text)
64 }
65
66 #[must_use]
70 pub fn with_renderer(mut self, renderer: Box<dyn crate::render::PdfRenderer>) -> Self {
71 self.renderer = Some(renderer);
72 self
73 }
74
75 #[must_use]
77 pub fn with_config(mut self, config: OcrConfig) -> Self {
78 self.config = config;
79 self
80 }
81
82 fn get_renderer<'a>(
87 &'a self,
88 input: &PdfInput,
89 ) -> Result<Box<dyn crate::render::PdfRenderer + 'a>> {
90 if let Some(ref renderer) = self.renderer {
91 return Ok(Box::new(StoredRendererAdapter(renderer.as_ref())));
94 }
95
96 match input {
97 PdfInput::Path(path) => self
98 .backend
99 .build_renderer(path)
100 .map_err(|e| render_error_to_pdf(&e)),
101 PdfInput::Bytes(bytes) => {
102 let tmp = tempfile::NamedTempFile::new()?;
103 std::fs::write(tmp.path(), bytes)?;
104 self.backend
105 .build_renderer(tmp.path())
106 .map_err(|e| render_error_to_pdf(&e))
107 }
108 _ => Err(PdfError::Other("unsupported PDF input type".to_owned())),
109 }
110 }
111
112 fn page_needs_ocr(&self, page: &PdfPageModel) -> bool {
114 match self.config.trigger {
115 OcrTrigger::Always => true,
116 OcrTrigger::OnEmptyPage => {
117 !page.blocks().iter().any(|b| {
119 matches!(
120 b.block_type(),
121 PdfBlockType::Paragraph | PdfBlockType::Heading
122 )
123 })
124 }
125 OcrTrigger::WhenTextSparse { threshold } => {
126 let total = page.blocks().len();
127 if total == 0 {
128 return true;
129 }
130 let text_count = page
131 .blocks()
132 .iter()
133 .filter(|b| {
134 matches!(
135 b.block_type(),
136 PdfBlockType::Paragraph
137 | PdfBlockType::Heading
138 | PdfBlockType::Code
139 | PdfBlockType::Footnote
140 | PdfBlockType::BlockQuote
141 )
142 })
143 .count();
144 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
148 let threshold_permille = (f64::from(threshold) * 1000.0).round() as usize;
149 text_count.saturating_mul(1000) < total.saturating_mul(threshold_permille)
150 }
151 }
152 }
153
154 fn ocr_page(
156 &self,
157 renderer: &dyn crate::render::PdfRenderer,
158 page_index: usize,
159 ) -> Result<(String, Option<f32>)> {
160 let render_config = RenderConfig {
161 dpi: self.config.render_dpi,
162 format: ImageFormat::Png,
163 ..RenderConfig::default()
164 };
165
166 let rendered_img = renderer
167 .render_page(page_index, &render_config)
168 .map_err(|e| render_error_to_pdf(&e))?;
169 let ocr_image = OcrImage::from_rendered(&rendered_img);
170
171 let ocr_result = self
172 .engine
173 .recognize(&ocr_image)
174 .map_err(|e| PdfError::Other(format!("OCR engine error: {e}")))?;
175
176 let text = ocr_result.text;
177 let confidence = ocr_result.confidence;
178
179 if text.trim().len() < self.config.min_text_length {
181 return Ok((String::new(), confidence));
182 }
183
184 Ok((text, confidence))
185 }
186}
187
188impl PdfMarkdownProcessor for OcrProcessor {
189 fn capabilities(&self) -> MarkdownProcessorCapabilities {
190 MarkdownProcessorCapabilities::new().with_ocr()
191 }
192
193 fn process(
194 &self,
195 input: &PdfInput,
196 document: PdfDocumentModel,
197 ) -> Result<(PdfDocumentModel, Vec<MarkdownWarning>)> {
198 let renderer = self.get_renderer(input)?;
199 let mut new_pages = Vec::with_capacity(document.page_count());
200 let mut warnings = Vec::new();
201
202 for page in document.pages() {
203 if !self.page_needs_ocr(page) {
204 new_pages.push(page.clone());
205 continue;
206 }
207
208 match self.ocr_page(renderer.as_ref(), page.index().value()) {
209 Ok((text, confidence)) => {
210 if text.is_empty() {
211 warnings.push(MarkdownWarning::OcrUnavailable {
213 page_index: page.index(),
214 });
215 new_pages.push(page.clone());
216 continue;
217 }
218
219 if let Some(conf) = confidence
221 && conf < self.config.min_confidence
222 {
223 warnings.push(MarkdownWarning::ProcessorFailed {
224 message: format!(
225 "OCR confidence {conf:.2} below threshold {:.2} on page {}",
226 self.config.min_confidence,
227 page.index().value() + 1
228 ),
229 });
230 }
231
232 let mut new_page = PdfPageModel::new(page.index());
234 if let (Some(w), Some(h)) = (page.width_pt(), page.height_pt()) {
235 new_page = new_page.with_dimensions(w, h);
236 }
237 new_page = new_page.with_rotation(page.rotation());
238
239 for block in page.blocks() {
241 new_page = new_page.with_block(block.clone());
242 }
243
244 let ocr_source = SourceLocation::new(page.index(), confidence.unwrap_or(0.5));
246 new_page = new_page.with_block(PdfBlock::paragraph(text, ocr_source));
247
248 new_pages.push(new_page);
249 }
250 Err(e) => {
251 warnings.push(MarkdownWarning::ProcessorFailed {
252 message: format!("OCR failed on page {}: {e}", page.index().value() + 1),
253 });
254 new_pages.push(page.clone());
255 }
256 }
257 }
258
259 Ok((
260 PdfDocumentModel::new(document.metadata().clone(), new_pages),
261 warnings,
262 ))
263 }
264}