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}