//! Scan & OCR ▸ Recognize text: render pages, read the words in them and add an invisible text //! layer ([`Edit::AddOcrText`]), making scanned pages searchable and selectable. //! //! Recognition is slow (about a second a page), so it is split from the edit: [`Session::ocr_job`] //! captures what it needs, [`OcrJob::run`] works anywhere (the UI runs it on a worker thread) and //! [`Session::apply_ocr`] applies the result as one undoable step. use std::sync::{Arc, Mutex}; use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind}; pub use pdfcraft_ocr::{LANGUAGES, Models, Ocr, OcrError, PlacedWord}; use crate::{DocId, Edit, EditError, Session}; /// Recognize Text settings. #[derive(Clone, Debug, PartialEq)] pub struct OcrSettings { /// Resolution pages are rendered at for recognition (Acrobat's "Downsample to" choices). pub dpi: f32, /// Language code from [`LANGUAGES`] (the models read the Latin alphabet, whatever this says). pub language: String, /// Leave pages that already have text alone (Acrobat reports "page contains renderable /// text" and skips them). pub skip_text_pages: bool, } impl Default for OcrSettings { fn default() -> Self { OcrSettings { dpi: 300.0, language: "en".into(), skip_text_pages: true } } } /// What recognition found on one page. #[derive(Clone, Debug, PartialEq)] pub struct OcrPage { pub page: usize, pub words: Vec, /// Why the page was not read, if it was skipped. pub skipped: Option, } impl OcrPage { pub fn text(&self) -> String { self.words.iter().map(|w| w.text.as_str()).collect::>().join(" ") } } /// The recogniser, loaded once (it takes a moment) and shared. pub fn engine() -> Result, String> { static OCR: Mutex>> = Mutex::new(None); let mut slot = OCR.lock().unwrap_or_else(|e| e.into_inner()); if let Some(o) = slot.as_ref() { return Ok(o.clone()); } let o = Arc::new(Ocr::find().map_err(|e| e.to_string())?); *slot = Some(o.clone()); Ok(o) } /// Whether the recognition models are installed. pub fn available() -> bool { Models::find().is_some() } /// Everything recognition needs from a document, detached from the session. pub struct OcrJob { bytes: Arc>, password: Option, infos: Vec, /// Per page: the render scale (pixels per point) that shows its images at their own /// resolution, if it has any. native: Vec>, pub pages: Vec, pub settings: OcrSettings, } impl OcrJob { /// Read the pages. `progress(done, total)` is called before each page; returning `false` /// stops (the pages read so far are returned). pub fn run(self, ocr: &Ocr, mut progress: impl FnMut(usize, usize) -> bool) -> Vec { let config = RenderConfig { password: self.password.as_deref().map(Arc::from), ..Default::default() }; let mut r = PageRenderer::new(self.bytes.clone(), config); let mut out = Vec::new(); let total = self.pages.len(); for (done, &page) in self.pages.iter().enumerate() { if !progress(done, total) { break; } let Some(info) = self.infos.get(page) else { continue }; let skip = |why: &str| OcrPage { page, words: Vec::new(), skipped: Some(why.into()) }; if self.settings.skip_text_pages { let t = r.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() }); if t.text.as_ref().is_some_and(|t| !t.plain_text().trim().is_empty()) { out.push(skip("the page already contains text")); continue; } } // A scan is read at its own resolution (enlarging it only blurs the letters), at most // the chosen one. let wanted = self.settings.dpi.clamp(72.0, 600.0) / 72.0; let scale = self.native.get(page).copied().flatten().map_or(wanted, |n| n.clamp(1.0, wanted)); let shot = r.render(RenderRequest { page, scale, ..Default::default() }); if let Some(e) = shot.error { out.push(skip(&e)); continue; } let lines = match ocr.recognize(&shot.rgba, shot.width, shot.height) { Ok(l) => l, Err(e) => { out.push(skip(&e.to_string())); continue; } }; let scale = shot.width as f32 / info.width.max(1e-3); let to_user = |x: f32, y: f32| { let [u, v] = info.view_to_user(x / scale, y / scale); [u as f64, v as f64] }; let words = lines.iter().flat_map(|l| &l.words).map(|w| PlacedWord::place(w, to_user)).collect(); out.push(OcrPage { page, words, skipped: None }); } progress(total, total); out } } impl Session { /// Capture what recognising `pages` (0-based; empty = all) of document `id` needs. pub fn ocr_job(&self, id: DocId, pages: &[usize], settings: OcrSettings) -> Option { let doc = self.get(id)?; let all = doc.info.pages.len(); let pages: Vec = if pages.is_empty() { (0..all).collect() } else { pages.iter().copied().filter(|p| *p < all).collect() }; let native = (0..all) .map(|p| { if !pages.contains(&p) { return None; } doc.page_images(p) .iter() .filter(|i| i.width > 1 && i.height > 1) .map(|i| { let m = i.matrix; let (w, h) = (m[0].hypot(m[1]), m[2].hypot(m[3])); (i.width as f64 / w.max(1e-6)).max(i.height as f64 / h.max(1e-6)) as f32 }) .reduce(f32::max) }) .collect(); Some(OcrJob { bytes: doc.bytes.clone(), password: doc.password.clone(), infos: doc.info.pages.clone(), native, pages, settings }) } /// Add the words found by [`OcrJob::run`] as one undoable step. Returns the number of words. pub fn apply_ocr(&mut self, id: DocId, found: &[OcrPage]) -> Result { let edits: Vec = found.iter().filter(|p| !p.words.is_empty()).map(|p| Edit::AddOcrText { page: p.page, words: p.words.clone() }).collect(); let words = found.iter().map(|p| p.words.len()).sum(); if !edits.is_empty() { self.apply(id, Edit::Batch { label: "Recognize text".into(), edits })?; } Ok(words) } /// Recognise text on `pages` and add it, in one call (the CLI and agents use this). pub fn recognize_text(&mut self, id: DocId, pages: &[usize], settings: OcrSettings) -> Result, String> { let job = self.ocr_job(id, pages, settings).ok_or("no such document")?; if let Some(why) = self.get(id).and_then(|d| d.read_only_reason.clone()) { return Err(why); } let ocr = engine()?; let found = job.run(&ocr, |_, _| true); self.apply_ocr(id, &found).map_err(|e| e.to_string())?; Ok(found) } } /// What [`recognize_file`] did to one file. #[derive(Clone, Debug, PartialEq)] pub struct FileResult { /// The new file (incrementally saved), or the original when nothing was recognised. pub bytes: Arc>, pub pages: Vec, } impl FileResult { pub fn words(&self) -> usize { self.pages.iter().map(|p| p.words.len()).sum() } } /// Recognize text in multiple files: one PDF's bytes in, the searchable PDF out. `progress` /// works as for [`OcrJob::run`]. pub fn recognize_file( name: &str, bytes: Arc>, password: Option<&str>, settings: OcrSettings, ocr: &Ocr, progress: impl FnMut(usize, usize) -> bool, ) -> Result { let mut s = Session::new(); let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?; if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) { return Err(why); } let job = s.ocr_job(id, &[], settings).ok_or("the document could not be read")?; let pages = job.run(ocr, progress); if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 { return Ok(FileResult { bytes, pages }); } Ok(FileResult { bytes: s.save_bytes(id).map_err(|e| e.to_string())?, pages }) }