Files
pdfcraft/crates/engine/src/ocr.rs
T
Raj PatelClaude Opus 5.5copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rp6186030Brandon Thomas
634f3d1144 Issue #1028: OCR can read pictures on pages that already have text (#1184)
* Issue #1028: OCR can read pictures on pages that already have text

Recognize Text skipped every page with text, so a picture of text on a
page that also has real text never became searchable. The engine and
the ocr_recognize tool already had skip_text_pages, but the desktop
dialog never offered it, and turning it off read the page's own text
again and added it a second time, so search found every word twice.

- Engine: always collect the page's own glyph boxes; with
  skip_text_pages false, a recognised word at least half covered by
  them (an earlier OCR layer included) is left out. OcrPage counts
  those words as already_on_page. The default still skips pages with
  text.
- Dialog: "Also recognize pages that already have text", kept with the
  other choices and used by Recognize text in multiple files too.
- Tools: ocr_recognize reports already_on_page; ocr_recognize_files
  accepts skip_text_pages.
- The two new labels in the 15 catalogs that translate the OCR dialog.
- Synthetic tests (a picture of a sentence plus real text) for the
  engine, the tool and the dialog; the engine test fails with the
  filter turned off. Unit tests for the coverage check, including
  NaN, infinite, inverted and overlapping boxes.
- parity, docs/roadmap.md M10 row and the ROADMAP.md progress log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm

* Issue #1028: Czech labels for the OCR text-pages option

The Czech catalog was completed (#1066) while #1028 was in review, and
czech_covers_ui_literals requires every tl! literal: the two new
Recognize Text labels were missing, so the test failed after the merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm

* Contributors: credit rp6186030 as Raj Patel in the About window

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JqiZ658tGndQaHao5Q6mdm

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: rp6186030 <25706807+rp6186030@users.noreply.github.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 18:45:36 -04:00

277 lines
12 KiB
Rust

//! 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). When `false`, such pages are read too (a picture on a page that
/// also has text, #1028) and only the words that aren't already there are added.
pub skip_text_pages: bool,
}
/// A recognised word whose box is at least this much covered by the page's own text is that
/// text read again, not a word in a picture.
const ALREADY_ON_PAGE: f32 = 0.5;
/// How much of `word` the `text` boxes cover, from 0 to 1. Boxes are `[x0, y0, x1, y1]` in page
/// view space. Overlaps between the text boxes are counted twice (glyphs rarely overlap), so the
/// sum is capped at the whole word.
fn covered(word: [f32; 4], text: &[[f32; 4]]) -> f32 {
// `max(0.0)` also turns a NaN side into 0: a degenerate box covers nothing.
let area = |[x0, y0, x1, y1]: [f32; 4]| (x1 - x0).max(0.0) * (y1 - y0).max(0.0);
let total = area(word);
if !total.is_finite() || total <= 0.0 {
return 0.0;
}
let inside: f32 =
text.iter().map(|g| area([word[0].max(g[0]), word[1].max(g[1]), word[2].min(g[2]), word[3].min(g[3])])).filter(|a| a.is_finite()).sum();
(inside / total).min(1.0)
}
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<PlacedWord>,
/// Words recognised but left out because the page's own text already has them there.
pub already_on_page: usize,
/// Why the page was not read, if it was skipped.
pub skipped: Option<String>,
}
impl OcrPage {
pub fn text(&self) -> String {
self.words.iter().map(|w| w.text.as_str()).collect::<Vec<_>>().join(" ")
}
}
/// The recogniser, loaded once (it takes a moment) and shared.
pub fn engine() -> Result<Arc<Ocr>, String> {
static OCR: Mutex<Option<Arc<Ocr>>> = 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<Vec<u8>>,
password: Option<String>,
infos: Vec<PageInfo>,
/// Per page: the render scale (pixels per point) that shows its images at their own
/// resolution, if it has any.
native: Vec<Option<f32>>,
pub pages: Vec<usize>,
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<OcrPage> {
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(), already_on_page: 0, skipped: Some(why.into()) };
// The boxes of the page's own text (an earlier OCR layer's included), in view space.
let existing: Vec<[f32; 4]> = r
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
.unwrap_or_default();
if self.settings.skip_text_pages && !existing.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]
};
// Words the page's own text already has are not added a second time.
let (fresh, already): (Vec<_>, Vec<_>) =
lines.iter().flat_map(|l| &l.words).partition(|w| covered(w.rect.map(|v| v / scale), &existing) < ALREADY_ON_PAGE);
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect();
out.push(OcrPage { page, words, already_on_page: already.len(), 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<OcrJob> {
let doc = self.get(id)?;
let all = doc.info.pages.len();
let pages: Vec<usize> = 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<usize, EditError> {
let edits: Vec<Edit> =
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<Vec<OcrPage>, 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<Vec<u8>>,
pub pages: Vec<OcrPage>,
}
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<Vec<u8>>,
password: Option<&str>,
settings: OcrSettings,
ocr: &Ocr,
progress: impl FnMut(usize, usize) -> bool,
) -> Result<FileResult, String> {
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 })
}
#[cfg(test)]
mod tests {
use super::*;
/// #1028: a recognised word over the page's own text is that text again; one in an empty
/// part of the page (a picture) is new.
#[test]
fn words_over_existing_text_count_as_already_there() {
// Glyph em boxes of "Total" at 12 pt, five letters side by side.
let glyphs: Vec<[f32; 4]> = (0..5).map(|i| [100.0 + 7.0 * i as f32, 50.0, 107.0 + 7.0 * i as f32, 62.0]).collect();
// OCR's tight box around the same ink.
assert!(covered([100.5, 52.0, 134.0, 61.0], &glyphs) >= ALREADY_ON_PAGE);
// A word in a picture further down, and one beside the text on the same line.
assert_eq!(covered([100.0, 200.0, 140.0, 212.0], &glyphs), 0.0);
assert_eq!(covered([140.0, 52.0, 170.0, 61.0], &glyphs), 0.0);
// Touching the text's last letter is not enough.
assert!(covered([131.0, 52.0, 170.0, 61.0], &glyphs) < ALREADY_ON_PAGE);
// A page without text has nothing to cover a word.
assert_eq!(covered([100.5, 52.0, 134.0, 61.0], &[]), 0.0);
}
#[test]
fn coverage_of_overlapping_or_broken_boxes_stays_between_zero_and_one() {
// A glyph drawn twice (fake bold) does not cover more than the whole word.
let twice = [[0.0, 0.0, 10.0, 10.0], [0.0, 0.0, 10.0, 10.0]];
assert_eq!(covered([0.0, 0.0, 10.0, 10.0], &twice), 1.0);
// Empty, inverted and non-finite boxes cover nothing and are covered by nothing.
for word in [[5.0, 5.0, 5.0, 9.0], [9.0, 0.0, 1.0, 9.0], [f32::NAN, 0.0, 9.0, 9.0], [0.0, 0.0, f32::INFINITY, 9.0]] {
assert_eq!(covered(word, &twice), 0.0, "{word:?}");
}
let broken = [[f32::NAN; 4], [f32::NEG_INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::INFINITY]];
let c = covered([0.0, 0.0, 10.0, 10.0], &broken);
assert!((0.0..=1.0).contains(&c), "{c}");
}
}