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/// The invisible layer's font in the page resources: a distinctive name so it
197/// cannot collide with a font the page already declares.
198///
199/// Public because extraction reads it back: text set in this resource is the
200/// engine's own OCR layer, which the hidden-text drop keeps while author-hidden
201/// text goes.
202pub const OCR_FONT_NAME: &[u8] = b"PdfOcrFont";
203
204/// Insert an invisible text layer on a page using OCR results.
205///
206/// Uses rendering mode 3 (invisible) so text is searchable but not visible.
207/// The layer declares the font it sets ([`OCR_FONT_NAME`], Helvetica under
208/// WinAnsiEncoding) in the page resources: a `Tf` naming no resource is
209/// malformed, and readers that fall back to a default font drop every byte
210/// outside ASCII. Words are transcoded to WinAnsi -- a recognizer returns
211/// UTF-8, and raw UTF-8 bytes under a WinAnsi font decode as mojibake --
212/// with `?` for what WinAnsi cannot spell.
213fn insert_invisible_text_layer(
214    doc: &mut Document,
215    page_id: ObjectId,
216    ocr_result: &OcrPageResult,
217    media_box: &[f64; 4],
218    _dpi: u32,
219) -> Result<()> {
220    let page_width = media_box[2] - media_box[0];
221    let page_height = media_box[3] - media_box[1];
222    let img_w = ocr_result.image_width as f64;
223    let img_h = ocr_result.image_height as f64;
224
225    let scale_x = page_width / img_w;
226    let scale_y = page_height / img_h;
227
228    let mut ops = vec![
229        Operation::new("BT", vec![]),
230        // Set rendering mode 3 (invisible).
231        Operation::new("Tr", vec![Object::Integer(3)]),
232        Operation::new(
233            "Tf",
234            vec![Object::Name(OCR_FONT_NAME.to_vec()), Object::Real(10.0)],
235        ),
236    ];
237
238    for word in &ocr_result.words {
239        let [px0, py0, px1, _py1] = word.bbox_px;
240
241        // Convert pixel coordinates to PDF coordinates.
242        let pdf_x = media_box[0] + (px0 as f64) * scale_x;
243        // PDF y-axis is bottom-up, image y-axis is top-down.
244        let pdf_y = media_box[3] - (py0 as f64) * scale_y;
245
246        let word_width_px = (px1 - px0) as f64;
247        let word_width_pdf = word_width_px * scale_x;
248
249        // Approximate the natural width of the text at 10pt.
250        let natural_width = word.text.chars().count() as f64 * 10.0 * 0.5;
251        let h_scale = if natural_width > 0.0 {
252            (word_width_pdf / natural_width) * 100.0
253        } else {
254            100.0
255        };
256
257        // Set text position with Tm.
258        ops.push(Operation::new(
259            "Tm",
260            vec![
261                Object::Real(1.0),
262                Object::Real(0.0),
263                Object::Real(0.0),
264                Object::Real(1.0),
265                Object::Real(pdf_x as f32),
266                Object::Real(pdf_y as f32),
267            ],
268        ));
269        // Set horizontal scaling.
270        ops.push(Operation::new("Tz", vec![Object::Real(h_scale as f32)]));
271        // Show the text.
272        ops.push(Operation::new(
273            "Tj",
274            vec![Object::String(
275                encode_layer_string(&word.text),
276                lopdf::StringFormat::Literal,
277            )],
278        ));
279    }
280
281    ops.push(Operation::new("ET", vec![]));
282
283    let content = Content { operations: ops };
284    let encoded = content
285        .encode()
286        .map_err(|e| OcrError::Other(format!("failed to encode text layer: {e}")))?;
287
288    let text_stream = Stream::new(dictionary! {}, encoded);
289    let text_id = doc.add_object(Object::Stream(text_stream));
290
291    // Append to page contents.
292    let existing = {
293        let page_obj = match doc.get_object(page_id) {
294            Ok(obj) => obj,
295            Err(_) => return Ok(()),
296        };
297        let page_dict = match page_obj {
298            Object::Dictionary(ref d) => d,
299            _ => return Ok(()),
300        };
301        page_dict.get(b"Contents").ok().cloned()
302    };
303
304    let new_contents = match existing {
305        Some(Object::Reference(existing_id)) => Object::Array(vec![
306            Object::Reference(existing_id),
307            Object::Reference(text_id),
308        ]),
309        Some(Object::Array(mut arr)) => {
310            arr.push(Object::Reference(text_id));
311            Object::Array(arr)
312        }
313        _ => Object::Reference(text_id),
314    };
315
316    if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
317        d.set("Contents", new_contents);
318    }
319
320    ensure_ocr_font_resource(doc, page_id)?;
321
322    Ok(())
323}
324
325/// A word as literal-string bytes: transcoded to WinAnsi. The writer escapes
326/// `\\`, `(` and `)` when the content encodes, so this must not escape them
327/// first -- pre-escaped bytes would be escaped again and decode with stray
328/// backslashes.
329fn encode_layer_string(text: &str) -> Vec<u8> {
330    text.chars().map(winansi_byte).collect()
331}
332
333/// One character in WinAnsiEncoding, or `?`.
334///
335/// WinAnsi is ASCII plus Latin-1 plus the punctuation in 0x80-0x9F; what it
336/// cannot spell -- Cyrillic, CJK, Arabic and the rest -- reads as `?` rather
337/// than as the mojibake raw UTF-8 bytes would decode to. A script outside
338/// WinAnsi wants a font with a `/ToUnicode` map, which this layer does not
339/// build yet.
340fn winansi_byte(c: char) -> u8 {
341    match c {
342        c if (c as u32) < 0x80 => c as u8,
343        '€' => 0x80,
344        '‚' => 0x82,
345        'ƒ' => 0x83,
346        '„' => 0x84,
347        '…' => 0x85,
348        '†' => 0x86,
349        '‡' => 0x87,
350        'ˆ' => 0x88,
351        '‰' => 0x89,
352        'Š' => 0x8A,
353        '‹' => 0x8B,
354        'Œ' => 0x8C,
355        'Ž' => 0x8E,
356        '‘' => 0x91,
357        '’' => 0x92,
358        '“' => 0x93,
359        '”' => 0x94,
360        '•' => 0x95,
361        '–' => 0x96,
362        '—' => 0x97,
363        '˜' => 0x98,
364        '™' => 0x99,
365        'š' => 0x9A,
366        '›' => 0x9B,
367        'œ' => 0x9C,
368        'ž' => 0x9E,
369        'Ÿ' => 0x9F,
370        c if (c as u32) < 0x100 => c as u8,
371        _ => b'?',
372    }
373}
374
375/// Declare [`OCR_FONT_NAME`] on the page: Helvetica under WinAnsiEncoding.
376///
377/// Merged into whatever resources the page already has -- a direct
378/// dictionary, a shared one behind a reference, or none at all -- chasing
379/// one reference at each level. Only what cannot be resolved is replaced;
380/// the page's own fonts are never orphaned. Anything unrecognized falls back
381/// to fresh resources on the page: the layer must name a font that exists,
382/// whatever shape the page came in.
383fn ensure_ocr_font_resource(doc: &mut Document, page_id: ObjectId) -> Result<()> {
384    let font_id = doc.add_object(Object::Dictionary(dictionary! {
385        "Type" => "Font",
386        "Subtype" => "Type1",
387        "BaseFont" => "Helvetica",
388        "Encoding" => "WinAnsiEncoding",
389    }));
390    let font_ref = Object::Reference(font_id);
391
392    let resources = {
393        let page_obj = match doc.get_object(page_id) {
394            Ok(obj) => obj,
395            Err(_) => return Ok(()),
396        };
397        match page_obj {
398            Object::Dictionary(ref d) => d.get(b"Resources").ok().cloned(),
399            _ => return Ok(()),
400        }
401    };
402
403    match resources {
404        // Shared resources: extend the shared dictionary in place.
405        Some(Object::Reference(resources_id)) => {
406            let fonts = match doc.get_object(resources_id) {
407                Ok(Object::Dictionary(d)) => d.get(b"Font").ok().cloned(),
408                _ => {
409                    set_fresh_resources(doc, page_id, font_ref);
410                    return Ok(());
411                }
412            };
413            match fonts {
414                Some(Object::Reference(fonts_id)) => {
415                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(fonts_id) {
416                        d.set(OCR_FONT_NAME, font_ref);
417                    } else if let Ok(Object::Dictionary(d)) = doc.get_object_mut(resources_id) {
418                        d.set("Font", fresh_fonts(font_ref));
419                    }
420                }
421                Some(Object::Dictionary(_)) => {
422                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(resources_id) {
423                        if let Ok(Object::Dictionary(fonts)) = d.get_mut(b"Font") {
424                            fonts.set(OCR_FONT_NAME, font_ref);
425                        }
426                    }
427                }
428                _ => {
429                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(resources_id) {
430                        d.set("Font", fresh_fonts(font_ref));
431                    }
432                }
433            }
434        }
435        // Direct resources: merge and write back.
436        Some(Object::Dictionary(mut dict)) => {
437            let write_back = match dict.get(b"Font").ok().cloned() {
438                Some(Object::Reference(fonts_id)) => {
439                    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(fonts_id) {
440                        d.set(OCR_FONT_NAME, font_ref);
441                        false
442                    } else {
443                        dict.set("Font", fresh_fonts(font_ref));
444                        true
445                    }
446                }
447                Some(Object::Dictionary(_)) => {
448                    if let Ok(Object::Dictionary(fonts)) = dict.get_mut(b"Font") {
449                        fonts.set(OCR_FONT_NAME, font_ref);
450                    }
451                    true
452                }
453                _ => {
454                    dict.set("Font", fresh_fonts(font_ref));
455                    true
456                }
457            };
458            if write_back {
459                if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page_id) {
460                    d.set("Resources", Object::Dictionary(dict));
461                }
462            }
463        }
464        _ => set_fresh_resources(doc, page_id, font_ref),
465    }
466    Ok(())
467}
468
469/// A `/Font` dictionary holding only the layer's font.
470fn fresh_fonts(font_ref: Object) -> Object {
471    Object::Dictionary(dictionary! {
472        OCR_FONT_NAME => font_ref,
473    })
474}
475
476/// Fresh `/Resources` on the page, for a page whose resources cannot be
477/// merged into. The last resort; the merge above is always tried first.
478fn set_fresh_resources(doc: &mut Document, page_id: ObjectId, font_ref: Object) {
479    if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page_id) {
480        d.set(
481            "Resources",
482            Object::Dictionary(dictionary! {
483                "Font" => fresh_fonts(font_ref),
484            }),
485        );
486    }
487}
488
489/// Get the MediaBox for a page.
490fn get_media_box(doc: &Document, page_id: ObjectId) -> [f64; 4] {
491    let default_box = [0.0, 0.0, 612.0, 792.0];
492
493    let page_obj = match doc.get_object(page_id) {
494        Ok(obj) => obj,
495        Err(_) => return default_box,
496    };
497
498    let page_dict = match page_obj {
499        Object::Dictionary(ref d) => d,
500        _ => return default_box,
501    };
502
503    match page_dict.get(b"MediaBox") {
504        Ok(Object::Array(arr)) => {
505            if arr.len() >= 4 {
506                let vals: Vec<f64> = arr
507                    .iter()
508                    .filter_map(|v| match v {
509                        Object::Integer(i) => Some(*i as f64),
510                        Object::Real(f) => Some(*f as f64),
511                        _ => None,
512                    })
513                    .collect();
514                if vals.len() >= 4 {
515                    [vals[0], vals[1], vals[2], vals[3]]
516                } else {
517                    default_box
518                }
519            } else {
520                default_box
521            }
522        }
523        _ => default_box,
524    }
525}
526
527/// Get content stream bytes for a page.
528fn get_page_content_bytes(doc: &Document, page_id: ObjectId) -> Option<Vec<u8>> {
529    Some(doc.get_page_content(page_id))
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use crate::engine::{NoOpEngine, OcrPageResult, OcrWord};
536
537    /// A mock OCR engine that returns predetermined results.
538    struct MockEngine {
539        result: OcrPageResult,
540    }
541
542    impl MockEngine {
543        fn new(words: Vec<OcrWord>) -> Self {
544            let confidence = if words.is_empty() {
545                0.0
546            } else {
547                words.iter().map(|w| w.confidence).sum::<f32>() / words.len() as f32
548            };
549            Self {
550                result: OcrPageResult {
551                    words,
552                    confidence,
553                    image_width: 600,
554                    image_height: 800,
555                },
556            }
557        }
558    }
559
560    impl OcrEngine for MockEngine {
561        fn recognize(
562            &self,
563            _image_data: &[u8],
564            _width: u32,
565            _height: u32,
566            _dpi: u32,
567        ) -> std::result::Result<OcrPageResult, String> {
568            Ok(self.result.clone())
569        }
570
571        fn supported_languages(&self) -> Vec<String> {
572            vec!["eng".to_string()]
573        }
574    }
575
576    /// Helper: create a doc with a scanned page (no text content).
577    fn make_scanned_doc() -> Document {
578        let mut doc = Document::with_version("1.7");
579
580        // Page with only graphics (no text operators).
581        let content_stream =
582            Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im0 Do Q".to_vec());
583        let content_id = doc.add_object(Object::Stream(content_stream));
584
585        let page_dict = dictionary! {
586            "Type" => "Page",
587            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
588            "Contents" => Object::Reference(content_id),
589        };
590        let page_id = doc.add_object(Object::Dictionary(page_dict));
591
592        let pages_dict = dictionary! {
593            "Type" => "Pages",
594            "Kids" => vec![Object::Reference(page_id)],
595            "Count" => 1_i64,
596        };
597        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
598
599        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
600            d.set("Parent", Object::Reference(pages_id));
601        }
602
603        let catalog = dictionary! {
604            "Type" => "Catalog",
605            "Pages" => Object::Reference(pages_id),
606        };
607        let catalog_id = doc.add_object(Object::Dictionary(catalog));
608        doc.trailer.set("Root", Object::Reference(catalog_id));
609
610        doc
611    }
612
613    /// Helper: create a doc with a text page.
614    fn make_text_doc() -> Document {
615        let mut doc = Document::with_version("1.7");
616
617        let content_stream = Stream::new(
618            dictionary! {},
619            b"BT /F1 12 Tf (This is a text page with enough characters to pass threshold) Tj ET"
620                .to_vec(),
621        );
622        let content_id = doc.add_object(Object::Stream(content_stream));
623
624        let page_dict = dictionary! {
625            "Type" => "Page",
626            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
627            "Contents" => Object::Reference(content_id),
628        };
629        let page_id = doc.add_object(Object::Dictionary(page_dict));
630
631        let pages_dict = dictionary! {
632            "Type" => "Pages",
633            "Kids" => vec![Object::Reference(page_id)],
634            "Count" => 1_i64,
635        };
636        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
637
638        if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(page_id) {
639            d.set("Parent", Object::Reference(pages_id));
640        }
641
642        let catalog = dictionary! {
643            "Type" => "Catalog",
644            "Pages" => Object::Reference(pages_id),
645        };
646        let catalog_id = doc.add_object(Object::Dictionary(catalog));
647        doc.trailer.set("Root", Object::Reference(catalog_id));
648
649        doc
650    }
651
652    /// Helper: create a multi-page doc (mix of scanned and text).
653    fn make_mixed_doc() -> Document {
654        let mut doc = Document::with_version("1.7");
655        let mut page_ids = Vec::new();
656
657        // Page 1: scanned (no text).
658        let content1 = Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im0 Do Q".to_vec());
659        let c1 = doc.add_object(Object::Stream(content1));
660        let p1 = dictionary! {
661            "Type" => "Page",
662            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
663            "Contents" => Object::Reference(c1),
664        };
665        let p1_id = doc.add_object(Object::Dictionary(p1));
666        page_ids.push(p1_id);
667
668        // Page 2: text page.
669        let content2 = Stream::new(
670            dictionary! {},
671            b"BT /F1 12 Tf (Enough text content to pass the threshold) Tj ET".to_vec(),
672        );
673        let c2 = doc.add_object(Object::Stream(content2));
674        let p2 = dictionary! {
675            "Type" => "Page",
676            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
677            "Contents" => Object::Reference(c2),
678        };
679        let p2_id = doc.add_object(Object::Dictionary(p2));
680        page_ids.push(p2_id);
681
682        // Page 3: scanned.
683        let content3 = Stream::new(dictionary! {}, b"q 612 0 0 792 0 0 cm /Im1 Do Q".to_vec());
684        let c3 = doc.add_object(Object::Stream(content3));
685        let p3 = dictionary! {
686            "Type" => "Page",
687            "MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
688            "Contents" => Object::Reference(c3),
689        };
690        let p3_id = doc.add_object(Object::Dictionary(p3));
691        page_ids.push(p3_id);
692
693        let kids: Vec<Object> = page_ids.iter().map(|id| Object::Reference(*id)).collect();
694        let pages_dict = dictionary! {
695            "Type" => "Pages",
696            "Kids" => kids,
697            "Count" => Object::Integer(page_ids.len() as i64),
698        };
699        let pages_id = doc.add_object(Object::Dictionary(pages_dict));
700
701        for &pid in &page_ids {
702            if let Ok(Object::Dictionary(ref mut d)) = doc.get_object_mut(pid) {
703                d.set("Parent", Object::Reference(pages_id));
704            }
705        }
706
707        let catalog = dictionary! {
708            "Type" => "Catalog",
709            "Pages" => Object::Reference(pages_id),
710        };
711        let catalog_id = doc.add_object(Object::Dictionary(catalog));
712        doc.trailer.set("Root", Object::Reference(catalog_id));
713
714        doc
715    }
716
717    /// Dummy render function for tests.
718    fn dummy_render(
719        _doc: &Document,
720        _page_num: u32,
721        _dpi: u32,
722    ) -> std::result::Result<(Vec<u8>, u32, u32), String> {
723        Ok((vec![0u8; 600 * 800 * 3], 600, 800))
724    }
725
726    #[test]
727    fn scanned_page_needs_ocr() {
728        let doc = make_scanned_doc();
729        let pages = doc.get_pages();
730        let page_id = *pages.get(&1).unwrap();
731        assert!(page_needs_ocr(&doc, page_id, 10));
732    }
733
734    #[test]
735    fn text_page_does_not_need_ocr() {
736        let doc = make_text_doc();
737        let pages = doc.get_pages();
738        let page_id = *pages.get(&1).unwrap();
739        assert!(!page_needs_ocr(&doc, page_id, 10));
740    }
741
742    #[test]
743    fn noop_engine_processes_scanned() {
744        let mut doc = make_scanned_doc();
745        let engine = NoOpEngine;
746        let config = OcrConfig::default();
747
748        let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
749        assert_eq!(report.pages.len(), 1);
750        assert!(report.pages[0].ocr_needed);
751        assert_eq!(report.pages[0].words_recognized, 0);
752    }
753
754    #[test]
755    fn text_page_skipped_by_pipeline() {
756        let mut doc = make_text_doc();
757        let engine = NoOpEngine;
758        let config = OcrConfig::default();
759
760        let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
761        assert_eq!(report.pages.len(), 1);
762        assert!(!report.pages[0].ocr_needed);
763        assert_eq!(report.pages_processed, 0);
764    }
765
766    #[test]
767    fn mock_engine_inserts_invisible_text() {
768        let mut doc = make_scanned_doc();
769        let engine = MockEngine::new(vec![
770            OcrWord {
771                text: "Hello".to_string(),
772                bbox_px: [10, 20, 100, 40],
773                confidence: 0.95,
774            },
775            OcrWord {
776                text: "World".to_string(),
777                bbox_px: [110, 20, 200, 40],
778                confidence: 0.90,
779            },
780        ]);
781        let config = OcrConfig::default();
782
783        let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
784        assert_eq!(report.pages_processed, 1);
785        assert_eq!(report.total_words, 2);
786        assert!(report.pages[0].ocr_needed);
787        assert_eq!(report.pages[0].words_recognized, 2);
788    }
789
790    #[test]
791    fn ocr_specific_pages() {
792        let mut doc = make_mixed_doc();
793        let engine = NoOpEngine;
794        let config = OcrConfig {
795            pages: vec![1],
796            ..Default::default()
797        };
798
799        let report = make_searchable(&mut doc, &engine, &config, dummy_render).unwrap();
800        // Only page 1 should be in the report.
801        assert_eq!(report.pages.len(), 1);
802        assert_eq!(report.pages[0].page, 1);
803    }
804
805    #[test]
806    fn ocr_page_out_of_range() {
807        let mut doc = make_scanned_doc();
808        let engine = NoOpEngine;
809        let config = OcrConfig {
810            pages: vec![5],
811            ..Default::default()
812        };
813
814        let result = make_searchable(&mut doc, &engine, &config, dummy_render);
815        assert!(result.is_err());
816    }
817
818    /// The font the invisible layer sets, resolved from the page resources.
819    fn layer_font(doc: &Document, page_num: u32) -> lopdf::Dictionary {
820        let pages = doc.get_pages();
821        let page_id = *pages.get(&page_num).unwrap();
822        let page = doc.get_object(page_id).unwrap();
823        let resources = match page {
824            Object::Dictionary(d) => d.get(b"Resources").unwrap().clone(),
825            _ => panic!("page is no dictionary"),
826        };
827        let fonts = match &resources {
828            Object::Dictionary(d) => d.get(b"Font").unwrap().clone(),
829            Object::Reference(id) => match doc.get_object(*id).unwrap() {
830                Object::Dictionary(d) => d.get(b"Font").unwrap().clone(),
831                other => panic!("shared resources are no dictionary: {other:?}"),
832            },
833            other => panic!("resources are no dictionary: {other:?}"),
834        };
835        let font_ref = match &fonts {
836            Object::Dictionary(d) => d.get(OCR_FONT_NAME).unwrap().clone(),
837            other => panic!("/Font is no dictionary: {other:?}"),
838        };
839        match font_ref {
840            Object::Reference(id) => match doc.get_object(id).unwrap() {
841                Object::Dictionary(d) => d.clone(),
842                other => panic!("font is no dictionary: {other:?}"),
843            },
844            other => panic!("font is no reference: {other:?}"),
845        }
846    }
847
848    #[test]
849    fn the_layer_declares_the_font_it_sets() {
850        let mut doc = make_scanned_doc();
851        let engine = MockEngine::new(vec![OcrWord {
852            text: "Hello".to_string(),
853            bbox_px: [10, 20, 100, 40],
854            confidence: 0.95,
855        }]);
856        make_searchable(&mut doc, &engine, &OcrConfig::default(), dummy_render).unwrap();
857
858        let font = layer_font(&doc, 1);
859        assert_eq!(font.get(b"BaseFont").unwrap(), &Object::from("Helvetica"));
860        assert_eq!(
861            font.get(b"Encoding").unwrap(),
862            &Object::from("WinAnsiEncoding")
863        );
864        // And the layer sets that name, not an undeclared one.
865        let pages = doc.get_pages();
866        let content = doc.get_page_content(*pages.get(&1).unwrap());
867        assert!(content.windows(11).any(|w| w == b"/PdfOcrFont"));
868    }
869
870    #[test]
871    fn the_layer_keeps_a_page_own_font() {
872        let mut doc = make_scanned_doc();
873        // A page font of the page's own, which the layer must not orphan.
874        let own_font = doc.add_object(Object::Dictionary(dictionary! {
875            "Type" => "Font",
876            "Subtype" => "Type1",
877            "BaseFont" => "Times-Roman",
878        }));
879        let pages = doc.get_pages();
880        let page_id = *pages.get(&1).unwrap();
881        if let Ok(Object::Dictionary(d)) = doc.get_object_mut(page_id) {
882            d.set(
883                "Resources",
884                Object::Dictionary(dictionary! {
885                    "Font" => Object::Dictionary(dictionary! {
886                        "F1" => Object::Reference(own_font),
887                    }),
888                }),
889            );
890        }
891        let engine = MockEngine::new(vec![OcrWord {
892            text: "Hello".to_string(),
893            bbox_px: [10, 20, 100, 40],
894            confidence: 0.95,
895        }]);
896        make_searchable(&mut doc, &engine, &OcrConfig::default(), dummy_render).unwrap();
897
898        let page = doc.get_object(page_id).unwrap().clone();
899        let fonts = match &page {
900            Object::Dictionary(d) => match d.get(b"Resources").unwrap() {
901                Object::Dictionary(r) => match r.get(b"Font").unwrap() {
902                    Object::Dictionary(f) => f.clone(),
903                    other => panic!("/Font is no dictionary: {other:?}"),
904                },
905                other => panic!("resources are no dictionary: {other:?}"),
906            },
907            _ => panic!("page is no dictionary"),
908        };
909        assert_eq!(
910            fonts.get(b"F1").unwrap(),
911            &Object::Reference(own_font),
912            "the page's own font survives the layer"
913        );
914        assert!(
915            fonts.get(OCR_FONT_NAME).is_ok(),
916            "the layer's font is declared beside it"
917        );
918    }
919
920    #[test]
921    fn parentheses_and_backslashes_in_words_do_not_break_the_layer() {
922        let mut doc = make_scanned_doc();
923        let engine = MockEngine::new(vec![OcrWord {
924            text: "a(b)\\c".to_string(),
925            bbox_px: [10, 20, 100, 40],
926            confidence: 0.95,
927        }]);
928        make_searchable(&mut doc, &engine, &OcrConfig::default(), dummy_render).unwrap();
929
930        let pages = doc.get_pages();
931        let content = doc.get_page_content(*pages.get(&1).unwrap());
932        // The writer escapes the backslash; the balanced parentheses stand.
933        // What matters is that the stream decodes and the word reads whole.
934        assert!(
935            content.windows(9).any(|w| w == b"(a(b)\\\\c)"),
936            "the word lands in the content: {content:?}"
937        );
938        let decoded = Content::decode(&content).unwrap();
939        let shown: Vec<Vec<u8>> = decoded
940            .operations
941            .iter()
942            .filter(|op| op.operator == "Tj")
943            .filter_map(|op| op.operands.first())
944            .filter_map(|o| match o {
945                Object::String(bytes, _) => Some(bytes.clone()),
946                _ => None,
947            })
948            .collect();
949        assert!(
950            shown.iter().any(|s| s == b"a(b)\\c"),
951            "the word decodes whole: {shown:?}"
952        );
953    }
954
955    #[test]
956    fn winansi_spelling_and_question_marks() {
957        assert_eq!(winansi_byte('A'), b'A');
958        assert_eq!(winansi_byte('é'), 0xE9);
959        assert_eq!(winansi_byte('€'), 0x80);
960        assert_eq!(winansi_byte('中'), b'?');
961        assert_eq!(encode_layer_string("café"), b"caf\xE9".to_vec());
962    }
963}