Skip to main content

pdf_engine/
ocr_searchable.rs

1//! OCR before extraction: read a searchable copy instead of the document.
2//!
3//! [`PdfDocument::searchable_copy`](crate::PdfDocument::searchable_copy) runs
4//! `pdf-ocr`'s hidden-text-layer path over an in-memory copy of the document:
5//! each page the window asks for is rendered at
6//! [`ExtractOptions::ocr_dpi`](crate::ExtractOptions::ocr_dpi) with this
7//! crate's renderer, the engine recognizes the raster, and the words land on
8//! the copy as an invisible text layer. Extraction then reads the copy, so
9//! recognized words go through the same segmentation as born-digital text --
10//! there is no second text path for OCR to disagree with.
11//!
12//! The copy is an incremental update over the original bytes, not a rewrite:
13//! only the layer's objects are appended, and everything the pipeline did
14//! not touch is read from the untouched original.
15//!
16//! [`OcrMode::Auto`](crate::OcrMode::Auto) recognizes the pages under
17//! `pdf-ocr`'s text threshold; [`OcrMode::Force`](crate::OcrMode::Force)
18//! recognizes every page in the window after dropping its native text, so the
19//! OCR layer replaces born-digital text rather than doubling it. Where the
20//! pipeline changes nothing -- `Auto` over born-digital pages, or no words
21//! recognized anywhere -- extraction reads the document itself: no roundtrip,
22//! no behavior change.
23//!
24
25// Copyright (c) 2026 Innovation Trigger B.V.
26//
27// PDFluent is available under two licences, at your option: the GNU AGPLv3, or
28// the PDFluent Commercial Licence. See the LICENSE file in this repository --
29// that file travels with the copy you received, which a URL does not.
30
31use crate::blocks::{ExtractOptions, OcrMode};
32use crate::document::PdfDocument;
33use crate::error::{EngineError, Result};
34#[cfg(feature = "ocr-cli")]
35use crate::render::RenderOptions;
36
37impl PdfDocument {
38    /// A copy of this document with an OCR text layer over the window's
39    /// pages, recognized by `engine` (feature `ocr-cli`).
40    ///
41    /// `options` steers which pages and how: [`OcrMode::Auto`] recognizes
42    /// the pages under `pdf-ocr`'s text threshold,
43    /// [`OcrMode::Force`] recognizes every page in the window after
44    /// dropping its native text, at [`ExtractOptions::ocr_dpi`] and in
45    /// [`ExtractOptions::ocr_language`] -- the language only steers the
46    /// engine this method is handed when that engine reads it. Pages
47    /// outside the window are untouched, and an empty window reads back an
48    /// unchanged copy.
49    ///
50    /// [`OcrMode::Never`] is a caller error here: nothing would be
51    /// recognized and the copy would be a roundtrip for nothing. Callers
52    /// that take "no OCR" as a normal case want
53    /// [`extract_blocks`](Self::extract_blocks), which branches before this.
54    #[cfg(feature = "ocr-cli")]
55    pub fn searchable_copy<E: pdf_ocr::OcrEngine>(
56        &self,
57        options: &ExtractOptions,
58        engine: &E,
59    ) -> Result<PdfDocument> {
60        options.validate(self.page_count())?;
61        if options.ocr == OcrMode::Never {
62            return Err(EngineError::ocr_failure(
63                "searchable_copy with ocr=Never: nothing would be recognized",
64            ));
65        }
66        Ok(self.searchable_copy_inner(options, engine)?.0)
67    }
68
69    /// The document extraction reads when OCR was asked for: `None` for the
70    pub(crate) fn ocr_copy_if_requested(
71        &self,
72        options: &ExtractOptions,
73    ) -> Result<Option<PdfDocument>> {
74        #[cfg(feature = "ocr-cli")]
75        {
76            if options.ocr == OcrMode::Never {
77                return Ok(None);
78            }
79            // A zero dpi never reaches here: `validate` rejects it before
80            // extraction branches into OCR, on every entry path.
81            if self.ocr_window_pages(options).is_empty() {
82                return Ok(None);
83            }
84            let engine = match pdf_ocr::TesseractCliEngine::new(&options.ocr_language) {
85                Ok(engine) => engine,
86                Err(e) if e.is_no_binary() && options.ocr == OcrMode::Auto => {
87                    return Ok(None);
88                }
89                Err(e) => return Err(EngineError::ocr_engine_failure(e)),
90            };
91            let (copy, changed) = self.searchable_copy_inner(options, &engine)?;
92            Ok(changed.then_some(copy))
93        }
94        #[cfg(not(feature = "ocr-cli"))]
95        {
96            match options.ocr {
97                // No engine compiled in is no engine found: Auto reads on.
98                OcrMode::Never | OcrMode::Auto => Ok(None),
99                OcrMode::Force => Err(EngineError::ocr_failure(
100                    "OCR was forced but this build has no OCR engine: it looks \
101                     for the tesseract binary (enable the `ocr-cli` feature)",
102                )),
103            }
104        }
105    }
106
107    /// The window's pages, 0-based, in document order.
108    #[cfg(feature = "ocr-cli")]
109    fn ocr_window_pages(&self, options: &ExtractOptions) -> Vec<usize> {
110        (0..self.page_count())
111            .filter(|page| options.holds(*page))
112            .collect()
113    }
114
115    /// The searchable copy and whether the pipeline changed anything: no
116    /// words recognized and nothing stripped means the copy is the document
117    /// again, and the caller reads the document itself instead.
118    #[cfg(feature = "ocr-cli")]
119    fn searchable_copy_inner<E: pdf_ocr::OcrEngine>(
120        &self,
121        options: &ExtractOptions,
122        engine: &E,
123    ) -> Result<(PdfDocument, bool)> {
124        // `validate` owns the dpi check: both callers (`searchable_copy`
125        // and `ocr_copy_if_requested` behind extraction) validate first.
126        let window = self.ocr_window_pages(options);
127        let source: &[u8] = self.pdf().data().as_ref();
128        if window.is_empty() {
129            let copy = PdfDocument::open(source.to_vec())
130                .map_err(|e| EngineError::ocr_failure(format!("reopen OCR copy: {e}")))?;
131            return Ok((copy, false));
132        }
133        let original = lopdf::Document::load_mem(source)
134            .map_err(|e| EngineError::ocr_failure(format!("read document for OCR: {e}")))?;
135        let mut working = original.clone();
136
137        let mut changed = false;
138        if options.ocr == OcrMode::Force {
139            changed |= strip_window_text(&mut working, &window)?;
140        }
141
142        let pages: Vec<u32> = window.iter().map(|page| *page as u32 + 1).collect();
143        let config = pdf_ocr::OcrConfig {
144            dpi: options.ocr_dpi,
145            pages,
146            ..pdf_ocr::OcrConfig::default()
147        };
148        // The pipeline hands its working copy to the render callback; the
149        // renderer reads this document instead. The copy only ever gains an
150        // invisible text layer, which renders nothing, so both read the same
151        // pixels -- and for Force the raster keeps the native text, which is
152        // what the recognizer should see.
153        let report = pdf_ocr::make_searchable(&mut working, engine, &config, |_, page, dpi| {
154            self.render_page_rgb(page - 1, dpi)
155        })
156        .map_err(|e| EngineError::ocr_failure(e.to_string()))?;
157        changed |= report.total_words > 0;
158
159        let bytes = incremental_bytes(source, &original, &working)?;
160        let mut copy = PdfDocument::open(bytes)
161            .map_err(|e| EngineError::ocr_failure(format!("reopen OCR copy: {e}")))?;
162        copy.adopt_settings(self);
163        Ok((copy, changed))
164    }
165
166    /// Page `index` (0-based) at `dpi` as RGB row-major pixels for OCR.
167    #[cfg(feature = "ocr-cli")]
168    fn render_page_rgb(
169        &self,
170        index: u32,
171        dpi: u32,
172    ) -> std::result::Result<(Vec<u8>, u32, u32), String> {
173        let options = RenderOptions {
174            dpi: f64::from(dpi),
175            ..RenderOptions::default()
176        };
177        let rendered = self
178            .render_page(index as usize, &options)
179            .map_err(|e| e.to_string())?;
180        let mut rgb = Vec::with_capacity(rendered.width as usize * rendered.height as usize * 3);
181        for px in rendered.pixels.chunks_exact(4) {
182            rgb.extend_from_slice(&px[..3]);
183        }
184        Ok((rgb, rendered.width, rendered.height))
185    }
186}
187
188/// The working copy as an incremental update over the original bytes.
189///
190/// Only objects the pipeline added or changed are appended; everything else
191/// is read from the original bytes untouched. A full rewrite would
192/// re-serialize streams this parser misreads -- a quirk the lenient reader
193/// tolerates, like the scan-source fixture's `//Length`, would come back
194/// empty and the copy would lose native text. The facade's incremental save
195/// is the same shape.
196#[cfg(feature = "ocr-cli")]
197fn incremental_bytes(
198    source: &[u8],
199    original: &lopdf::Document,
200    working: &lopdf::Document,
201) -> Result<Vec<u8>> {
202    let mut incremental =
203        lopdf::IncrementalDocument::create_from(source.to_vec(), original.clone());
204    for (object_id, object) in &working.objects {
205        let changed = match original.objects.get(object_id) {
206            Some(previous) => previous != object,
207            None => true,
208        };
209        if changed {
210            incremental
211                .new_document
212                .set_object(*object_id, object.clone());
213        }
214    }
215    incremental.new_document.version = working.version.clone();
216    for (key, value) in &working.trailer {
217        if key != b"Prev" && key != b"Size" {
218            incremental
219                .new_document
220                .trailer
221                .set(key.clone(), value.clone());
222        }
223    }
224    incremental.new_document.max_id = working.max_id;
225    let mut bytes = Vec::with_capacity(source.len() + 64 * 1024);
226    incremental
227        .save_to(&mut bytes)
228        .map_err(|e| EngineError::ocr_failure(format!("write OCR copy: {e}")))?;
229    Ok(bytes)
230}
231
232/// Drop the text-showing operators from the window's pages, for Force.
233///
234/// The OCR layer replaces the native text rather than doubling it; graphics,
235/// positioning and marked content stay. Returns whether any page was
236/// rewritten -- a page with no text-showing operators is left untouched, so
237/// a force pass over scanned pages roundtrips nothing. `pages` is 0-based.
238#[cfg(feature = "ocr-cli")]
239fn strip_window_text(doc: &mut lopdf::Document, pages: &[usize]) -> Result<bool> {
240    let ids = doc.get_pages();
241    let mut changed = false;
242    for &page in pages {
243        let number = page as u32 + 1;
244        let Some(&page_id) = ids.get(&number) else {
245            continue;
246        };
247        changed |= strip_page_text(doc, page_id)?;
248    }
249    Ok(changed)
250}
251
252/// Drop the text-showing operators from one page's content.
253///
254/// The full content -- every stream, decompressed -- decodes, loses `Tj`,
255/// `TJ`, `'` and `"`, and is written back as one plain stream when anything
256/// was lost. Concatenated streams read as one, so one stream reads the same;
257/// the orphaned originals stay in the file, which is valid and only costs
258/// bytes in memory. Returns whether the page was rewritten.
259///
260/// The strip sees what the working parser sees: on the rare page whose text
261/// it cannot parse (while the lenient reader still can), nothing is stripped
262/// and Force degrades to appending the layer, like Auto.
263#[cfg(feature = "ocr-cli")]
264fn strip_page_text(doc: &mut lopdf::Document, page_id: lopdf::ObjectId) -> Result<bool> {
265    let content = doc.get_page_content(page_id);
266    let mut decoded = match lopdf::content::Content::decode(&content) {
267        Ok(decoded) => decoded,
268        // Unparsable to the working parser while the lenient reader still
269        // reads it: nothing is stripped and Force degrades to appending the
270        // layer, like Auto, rather than failing the extraction.
271        Err(_) => return Ok(false),
272    };
273    let before = decoded.operations.len();
274    decoded
275        .operations
276        .retain(|op| !matches!(op.operator.as_str(), "Tj" | "TJ" | "'" | "\""));
277    if decoded.operations.len() == before {
278        return Ok(false);
279    }
280    let encoded = decoded
281        .encode()
282        .map_err(|e| EngineError::ocr_failure(format!("write stripped page for OCR: {e}")))?;
283    let stream_id = doc.add_object(lopdf::Object::Stream(lopdf::Stream::new(
284        lopdf::dictionary! {},
285        encoded,
286    )));
287    if let Ok(lopdf::Object::Dictionary(d)) = doc.get_object_mut(page_id) {
288        d.set("Contents", lopdf::Object::Reference(stream_id));
289    }
290    Ok(true)
291}