1use crate::engine::{OcrEngine, OcrPageResult};
13use crate::error::{OcrError, Result};
14use lopdf::content::{Content, Operation};
15use lopdf::{dictionary, Document, Object, ObjectId, Stream};
16
17#[derive(Debug, Clone)]
19pub struct OcrConfig {
20 pub dpi: u32,
22 pub text_threshold: usize,
24 pub pages: Vec<u32>,
26}
27
28impl Default for OcrConfig {
29 fn default() -> Self {
30 Self {
31 dpi: 300,
32 text_threshold: 10,
33 pages: Vec::new(),
34 }
35 }
36}
37
38#[derive(Debug, Clone)]
40pub struct OcrReport {
41 pub pages: Vec<OcrPageReport>,
43 pub pages_processed: usize,
45 pub total_words: usize,
47}
48
49#[derive(Debug, Clone)]
51pub struct OcrPageReport {
52 pub page: u32,
54 pub ocr_needed: bool,
56 pub words_recognized: usize,
58 pub confidence: f32,
60}
61
62pub fn make_searchable<
70 E: OcrEngine,
71 R: Fn(&Document, u32, u32) -> std::result::Result<(Vec<u8>, u32, u32), String>,
72>(
73 doc: &mut Document,
74 engine: &E,
75 config: &OcrConfig,
76 render_fn: R,
77) -> Result<OcrReport> {
78 let pages = doc.get_pages();
79 let total = pages.len() as u32;
80
81 let page_nums: Vec<u32> = if config.pages.is_empty() {
83 (1..=total).collect()
84 } else {
85 for &p in &config.pages {
87 if p == 0 || p > total {
88 return Err(OcrError::PageOutOfRange(p, total));
89 }
90 }
91 config.pages.clone()
92 };
93
94 let mut report = OcrReport {
95 pages: Vec::new(),
96 pages_processed: 0,
97 total_words: 0,
98 };
99
100 for &page_num in &page_nums {
101 let page_id = match pages.get(&page_num) {
102 Some(&id) => id,
103 None => continue,
104 };
105
106 let needs_ocr = page_needs_ocr(doc, page_id, config.text_threshold);
107
108 if !needs_ocr {
109 report.pages.push(OcrPageReport {
110 page: page_num,
111 ocr_needed: false,
112 words_recognized: 0,
113 confidence: 1.0,
114 });
115 continue;
116 }
117
118 let (image_data, width, height) =
120 render_fn(doc, page_num, config.dpi).map_err(OcrError::Render)?;
121
122 let ocr_result = engine
124 .recognize(&image_data, width, height, config.dpi)
125 .map_err(OcrError::Engine)?;
126
127 let words_count = ocr_result.words.len();
128 let confidence = ocr_result.confidence;
129
130 if !ocr_result.words.is_empty() {
132 let media_box = get_media_box(doc, page_id);
133 insert_invisible_text_layer(doc, page_id, &ocr_result, &media_box, config.dpi)?;
134 }
135
136 report.pages.push(OcrPageReport {
137 page: page_num,
138 ocr_needed: true,
139 words_recognized: words_count,
140 confidence,
141 });
142 report.pages_processed += 1;
143 report.total_words += words_count;
144 }
145
146 Ok(report)
147}
148
149fn page_needs_ocr(doc: &Document, page_id: ObjectId, threshold: usize) -> bool {
151 let content_bytes = match get_page_content_bytes(doc, page_id) {
152 Some(bytes) => bytes,
153 None => return true, };
155
156 let content = match Content::decode(&content_bytes) {
157 Ok(c) => c,
158 Err(_) => return true,
159 };
160
161 let mut char_count = 0;
162 for op in &content.operations {
163 match op.operator.as_str() {
164 "Tj" => {
165 for operand in &op.operands {
166 if let Object::String(bytes, _) = operand {
167 char_count += bytes.len();
168 }
169 }
170 }
171 "TJ" => {
172 for operand in &op.operands {
173 if let Object::Array(arr) = operand {
174 for item in arr {
175 if let Object::String(bytes, _) = item {
176 char_count += bytes.len();
177 }
178 }
179 }
180 }
181 }
182 "'" | "\"" => {
183 for operand in &op.operands {
184 if let Object::String(bytes, _) = operand {
185 char_count += bytes.len();
186 }
187 }
188 }
189 _ => {}
190 }
191 }
192
193 char_count < threshold
194}
195
196fn insert_invisible_text_layer(
200 doc: &mut Document,
201 page_id: ObjectId,
202 ocr_result: &OcrPageResult,
203 media_box: &[f64; 4],
204 _dpi: u32,
205) -> Result<()> {
206 let page_width = media_box[2] - media_box[0];
207 let page_height = media_box[3] - media_box[1];
208 let img_w = ocr_result.image_width as f64;
209 let img_h = ocr_result.image_height as f64;
210
211 let scale_x = page_width / img_w;
212 let scale_y = page_height / img_h;
213
214 let mut ops = vec![
215 Operation::new("BT", vec![]),
216 Operation::new("Tr", vec![Object::Integer(3)]),
218 Operation::new(
219 "Tf",
220 vec![Object::Name(b"Helvetica".to_vec()), Object::Real(10.0)],
221 ),
222 ];
223
224 for word in &ocr_result.words {
225 let [px0, py0, px1, _py1] = word.bbox_px;
226
227 let pdf_x = media_box[0] + (px0 as f64) * scale_x;
229 let pdf_y = media_box[3] - (py0 as f64) * scale_y;
231
232 let word_width_px = (px1 - px0) as f64;
233 let word_width_pdf = word_width_px * scale_x;
234
235 let natural_width = word.text.len() as f64 * 10.0 * 0.5;
237 let h_scale = if natural_width > 0.0 {
238 (word_width_pdf / natural_width) * 100.0
239 } else {
240 100.0
241 };
242
243 ops.push(Operation::new(
245 "Tm",
246 vec![
247 Object::Real(1.0),
248 Object::Real(0.0),
249 Object::Real(0.0),
250 Object::Real(1.0),
251 Object::Real(pdf_x as f32),
252 Object::Real(pdf_y as f32),
253 ],
254 ));
255 ops.push(Operation::new("Tz", vec![Object::Real(h_scale as f32)]));
257 ops.push(Operation::new(
259 "Tj",
260 vec![Object::String(
261 word.text.as_bytes().to_vec(),
262 lopdf::StringFormat::Literal,
263 )],
264 ));
265 }
266
267 ops.push(Operation::new("ET", vec![]));
268
269 let content = Content { operations: ops };
270 let encoded = content
271 .encode()
272 .map_err(|e| OcrError::Other(format!("failed to encode text layer: {e}")))?;
273
274 let text_stream = Stream::new(dictionary! {}, encoded);
275 let text_id = doc.add_object(Object::Stream(text_stream));
276
277 let existing = {
279 let page_obj = match doc.get_object(page_id) {
280 Ok(obj) => obj,
281 Err(_) => return Ok(()),
282 };
283 let page_dict = match page_obj {
284 Object::Dictionary(ref d) => d,
285 _ => return Ok(()),
286 };
287 page_dict.get(b"Contents").ok().cloned()
288 };
289
290 let new_contents = match existing {
291 Some(Object::Reference(existing_id)) => Object::Array(vec![
292 Object::Reference(existing_id),
293 Object::Reference(text_id),
294 ]),
295 Some(Object::Array(mut arr)) => {
296 arr.push(Object::Reference(text_id));
297 Object::Array(arr)
298 }
299 _ => Object::Reference(text_id),
300 };
301
302 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
303 d.set("Contents", new_contents);
304 }
305
306 Ok(())
307}
308
309fn get_media_box(doc: &Document, page_id: ObjectId) -> [f64; 4] {
311 let default_box = [0.0, 0.0, 612.0, 792.0];
312
313 let page_obj = match doc.get_object(page_id) {
314 Ok(obj) => obj,
315 Err(_) => return default_box,
316 };
317
318 let page_dict = match page_obj {
319 Object::Dictionary(ref d) => d,
320 _ => return default_box,
321 };
322
323 match page_dict.get(b"MediaBox") {
324 Ok(Object::Array(arr)) => {
325 if arr.len() >= 4 {
326 let vals: Vec<f64> = arr
327 .iter()
328 .filter_map(|v| match v {
329 Object::Integer(i) => Some(*i as f64),
330 Object::Real(f) => Some(*f as f64),
331 _ => None,
332 })
333 .collect();
334 if vals.len() >= 4 {
335 [vals[0], vals[1], vals[2], vals[3]]
336 } else {
337 default_box
338 }
339 } else {
340 default_box
341 }
342 }
343 _ => default_box,
344 }
345}
346
347fn get_page_content_bytes(doc: &Document, page_id: ObjectId) -> Option<Vec<u8>> {
349 Some(doc.get_page_content(page_id))
350}
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355 use crate::engine::{NoOpEngine, OcrPageResult, OcrWord};
356
357 struct MockEngine {
359 result: OcrPageResult,
360 }
361
362 impl MockEngine {
363 fn new(words: Vec<OcrWord>) -> Self {
364 let confidence = if words.is_empty() {
365 0.0
366 } else {
367 words.iter().map(|w| w.confidence).sum::<f32>() / words.len() as f32
368 };
369 Self {
370 result: OcrPageResult {
371 words,
372 confidence,
373 image_width: 600,
374 image_height: 800,
375 },
376 }
377 }
378 }
379
380 impl OcrEngine for MockEngine {
381 fn recognize(
382 &self,
383 _image_data: &[u8],
384 _width: u32,
385 _height: u32,
386 _dpi: u32,
387 ) -> std::result::Result<OcrPageResult, String> {
388 Ok(self.result.clone())
389 }
390
391 fn supported_languages(&self) -> Vec<String> {
392 vec!["eng".to_string()]
393 }
394 }
395
396 fn make_scanned_doc() -> Document {
398 let mut doc = Document::with_version("1.7");
399
400 let content_stream =
402 Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im0 Do Q".to_vec());
403 let content_id = doc.add_object(Object::Stream(content_stream));
404
405 let page_dict = dictionary! {
406 "Type" => "Page",
407 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
408 "Contents" => Object::Reference(content_id),
409 };
410 let page_id = doc.add_object(Object::Dictionary(page_dict));
411
412 let pages_dict = dictionary! {
413 "Type" => "Pages",
414 "Kids" => vec![Object::Reference(page_id)],
415 "Count" => 1_i64,
416 };
417 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
418
419 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
420 d.set("Parent", Object::Reference(pages_id));
421 }
422
423 let catalog = dictionary! {
424 "Type" => "Catalog",
425 "Pages" => Object::Reference(pages_id),
426 };
427 let catalog_id = doc.add_object(Object::Dictionary(catalog));
428 doc.trailer.set("Root", Object::Reference(catalog_id));
429
430 doc
431 }
432
433 fn make_text_doc() -> Document {
435 let mut doc = Document::with_version("1.7");
436
437 let content_stream = Stream::new(
438 dictionary! {},
439 b"BT /F1 12 Tf (This is a text page with enough characters to pass threshold) Tj ET"
440 .to_vec(),
441 );
442 let content_id = doc.add_object(Object::Stream(content_stream));
443
444 let page_dict = dictionary! {
445 "Type" => "Page",
446 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
447 "Contents" => Object::Reference(content_id),
448 };
449 let page_id = doc.add_object(Object::Dictionary(page_dict));
450
451 let pages_dict = dictionary! {
452 "Type" => "Pages",
453 "Kids" => vec![Object::Reference(page_id)],
454 "Count" => 1_i64,
455 };
456 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
457
458 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
459 d.set("Parent", Object::Reference(pages_id));
460 }
461
462 let catalog = dictionary! {
463 "Type" => "Catalog",
464 "Pages" => Object::Reference(pages_id),
465 };
466 let catalog_id = doc.add_object(Object::Dictionary(catalog));
467 doc.trailer.set("Root", Object::Reference(catalog_id));
468
469 doc
470 }
471
472 fn make_mixed_doc() -> Document {
474 let mut doc = Document::with_version("1.7");
475 let mut page_ids = Vec::new();
476
477 let content1 = Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im0 Do Q".to_vec());
479 let c1 = doc.add_object(Object::Stream(content1));
480 let p1 = dictionary! {
481 "Type" => "Page",
482 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
483 "Contents" => Object::Reference(c1),
484 };
485 let p1_id = doc.add_object(Object::Dictionary(p1));
486 page_ids.push(p1_id);
487
488 let content2 = Stream::new(
490 dictionary! {},
491 b"BT /F1 12 Tf (Enough text content to pass the threshold) Tj ET".to_vec(),
492 );
493 let c2 = doc.add_object(Object::Stream(content2));
494 let p2 = dictionary! {
495 "Type" => "Page",
496 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
497 "Contents" => Object::Reference(c2),
498 };
499 let p2_id = doc.add_object(Object::Dictionary(p2));
500 page_ids.push(p2_id);
501
502 let content3 = Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im1 Do Q".to_vec());
504 let c3 = doc.add_object(Object::Stream(content3));
505 let p3 = dictionary! {
506 "Type" => "Page",
507 "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
508 "Contents" => Object::Reference(c3),
509 };
510 let p3_id = doc.add_object(Object::Dictionary(p3));
511 page_ids.push(p3_id);
512
513 let kids: Vec<Object> = page_ids.iter().map(|id| Object::Reference(*id)).collect();
514 let pages_dict = dictionary! {
515 "Type" => "Pages",
516 "Kids" => kids,
517 "Count" => Object::Integer(page_ids.len() as i64),
518 };
519 let pages_id = doc.add_object(Object::Dictionary(pages_dict));
520
521 for &pid in &page_ids {
522 if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(pid) {
523 d.set("Parent", Object::Reference(pages_id));
524 }
525 }
526
527 let catalog = dictionary! {
528 "Type" => "Catalog",
529 "Pages" => Object::Reference(pages_id),
530 };
531 let catalog_id = doc.add_object(Object::Dictionary(catalog));
532 doc.trailer.set("Root", Object::Reference(catalog_id));
533
534 doc
535 }
536
537 fn dummy_render(
539 _doc: &Document,
540 _page_num: u32,
541 _dpi: u32,
542 ) -> std::result::Result<(Vec<u8>, u32, u32), String> {
543 Ok((vec![0u8; 600 * 800 * 3], 600, 800))
544 }
545
546 #[test]
547 fn scanned_page_needs_ocr() {
548 let doc = make_scanned_doc();
549 let pages = doc.get_pages();
550 let page_id = *pages.get(&1).unwrap();
551 assert!(page_needs_ocr(&doc, page_id, 10));
552 }
553
554 #[test]
555 fn text_page_does_not_need_ocr() {
556 let doc = make_text_doc();
557 let pages = doc.get_pages();
558 let page_id = *pages.get(&1).unwrap();
559 assert!(!page_needs_ocr(&doc, page_id, 10));
560 }
561
562 #[test]
563 fn noop_engine_processes_scanned() {
564 let mut doc = make_scanned_doc();
565 let engine = NoOpEngine;
566 let config = OcrConfig::default();
567
568 let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
569 assert_eq!(report.pages.len(), 1);
570 assert!(report.pages[0].ocr_needed);
571 assert_eq!(report.pages[0].words_recognized, 0);
572 }
573
574 #[test]
575 fn text_page_skipped_by_pipeline() {
576 let mut doc = make_text_doc();
577 let engine = NoOpEngine;
578 let config = OcrConfig::default();
579
580 let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
581 assert_eq!(report.pages.len(), 1);
582 assert!(!report.pages[0].ocr_needed);
583 assert_eq!(report.pages_processed, 0);
584 }
585
586 #[test]
587 fn mock_engine_inserts_invisible_text() {
588 let mut doc = make_scanned_doc();
589 let engine = MockEngine::new(vec![
590 OcrWord {
591 text: "Hello".to_string(),
592 bbox_px: [10, 20, 100, 40],
593 confidence: 0.95,
594 },
595 OcrWord {
596 text: "World".to_string(),
597 bbox_px: [110, 20, 200, 40],
598 confidence: 0.90,
599 },
600 ]);
601 let config = OcrConfig::default();
602
603 let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
604 assert_eq!(report.pages_processed, 1);
605 assert_eq!(report.total_words, 2);
606 assert!(report.pages[0].ocr_needed);
607 assert_eq!(report.pages[0].words_recognized, 2);
608 }
609
610 #[test]
611 fn ocr_specific_pages() {
612 let mut doc = make_mixed_doc();
613 let engine = NoOpEngine;
614 let config = OcrConfig {
615 pages: vec![1],
616 ..Default::default()
617 };
618
619 let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
620 assert_eq!(report.pages.len(), 1);
622 assert_eq!(report.pages[0].page, 1);
623 }
624
625 #[test]
626 fn ocr_page_out_of_range() {
627 let mut doc = make_scanned_doc();
628 let engine = NoOpEngine;
629 let config = OcrConfig {
630 pages: vec![5],
631 ..Default::default()
632 };
633
634 let result = make_searchable(&mut doc, &engine, &config, dummy_render);
635 assert!(result.is_err());
636 }
637}