Skip to main content

pdf_ocr/
pipeline.rs

1//! OCR pipeline: detect scanned pages, run OCR, insert invisible text layers.
2//!
3//! The pipeline takes a render callback to rasterize pages, avoiding a hard
4//! dependency on any particular rendering engine.
5
6// Copyright (c) 2026 Innovation Trigger B.V.
7//
8// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
9// the PDFluent Commercial Licence. See the LICENSE file in this repository --
10// that file travels with the copy you received, which a URL does not.
11
12use crate::engine::{OcrEngine, OcrPageResult};
13use crate::error::{OcrError, Result};
14use lopdf::content::{Content, Operation};
15use lopdf::{dictionary, Document, Object, ObjectId, Stream};
16
17/// Configuration for the OCR pipeline.
18#[derive(Debug, Clone)]
19pub struct OcrConfig {
20    /// Resolution for rendering pages (dots per inch).
21    pub dpi: u32,
22    /// Minimum character count in content stream to consider a page as already containing text.
23    pub text_threshold: usize,
24    /// Specific pages to process (empty = all pages).
25    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/// Report for the entire OCR process.
39#[derive(Debug, Clone)]
40pub struct OcrReport {
41    /// Per-page reports.
42    pub pages: Vec<OcrPageReport>,
43    /// Total number of pages processed.
44    pub pages_processed: usize,
45    /// Total number of words recognized.
46    pub total_words: usize,
47}
48
49/// Report for a single page.
50#[derive(Debug, Clone)]
51pub struct OcrPageReport {
52    /// Page number (1-based).
53    pub page: u32,
54    /// Whether OCR was needed (page was scanned).
55    pub ocr_needed: bool,
56    /// Number of words recognized.
57    pub words_recognized: usize,
58    /// Overall confidence.
59    pub confidence: f32,
60}
61
62/// Make a PDF searchable by running OCR on scanned pages.
63///
64/// # Arguments
65/// * `doc` - The PDF document to process.
66/// * `engine` - The OCR engine to use.
67/// * `config` - OCR configuration.
68/// * `render_fn` - A callback to render a page to an image: `(doc, page_num, dpi) -> (pixels, width, height)`.
69pub 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    // Determine which pages to process.
82    let page_nums: Vec<u32> = if config.pages.is_empty() {
83        (1..=total).collect()
84    } else {
85        // Validate page numbers.
86        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        // Render page to image.
119        let (image_data, width, height) =
120            render_fn(doc, page_num, config.dpi).map_err(OcrError::Render)?;
121
122        // Run OCR.
123        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        // Insert invisible text layer.
131        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
149/// Check whether a page needs OCR by counting text characters in the content stream.
150fn 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, // No content stream = likely scanned.
154    };
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
196/// Insert an invisible text layer on a page using OCR results.
197///
198/// Uses rendering mode 3 (invisible) so text is searchable but not visible.
199fn 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        // Set rendering mode 3 (invisible).
217        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        // Convert pixel coordinates to PDF coordinates.
228        let pdf_x = media_box[0] + (px0 as f64) * scale_x;
229        // PDF y-axis is bottom-up, image y-axis is top-down.
230        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        // Approximate the natural width of the text at 10pt.
236        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        // Set text position with Tm.
244        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        // Set horizontal scaling.
256        ops.push(Operation::new("Tz", vec![Object::Real(h_scale as f32)]));
257        // Show the text.
258        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    // Append to page contents.
278    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
309/// Get the MediaBox for a page.
310fn 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
347/// Get content stream bytes for a page.
348fn 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    /// A mock OCR engine that returns predetermined results.
358    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    /// Helper: create a doc with a scanned page (no text content).
397    fn make_scanned_doc() -> Document {
398        let mut doc = Document::with_version("1.7");
399
400        // Page with only graphics (no text operators).
401        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    /// Helper: create a doc with a text page.
434    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    /// Helper: create a multi-page doc (mix of scanned and text).
473    fn make_mixed_doc() -> Document {
474        let mut doc = Document::with_version("1.7");
475        let mut page_ids = Vec::new();
476
477        // Page 1: scanned (no text).
478        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        // Page 2: text page.
489        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        // Page 3: scanned.
503        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    /// Dummy render function for tests.
538    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        // Only page 1 should be in the report.
621        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}