Files
pdfcraft/crates/ui-egui/tests/ocr.rs
5cce1b247f M10: edit OCR text on scanned pages so the change shows (#1397)
* M10: edit OCR text on scanned pages so the change shows

Editing a recognised word on a scan rewrote it as invisible OCR text, so
the page still showed the old word, and the whole OCR layer grouped into
one paragraph that rewrapped when one word changed.

- An edited invisible paragraph is drawn visibly, over a cover in the
  paper colour sampled around it, in the scan's sampled ink colour
  (or the chosen colour).
- OCR words group into phrases: a table cell or a tab stop splits them
  and each scanned line stays apart; a phrase grows rightwards.
- Styled rewrites measure with the text matrix's horizontal scale, so
  stretched OCR words keep their widths instead of overlapping.
- The inline editor covers the scanned word and grows like the result.

* M10: size OCR text from each word's letters

A word box from recognition is tight around the ink, but the text layer
assumed every box spans Helvetica's ascender-descender height. A
capitals-only word such as "DT" spans only the cap height, so its text
was set about 23% too small and its baseline floated up; editing it
then drew the new word visibly smaller than the scanned one.

The em and baseline now come from the word's letters: cap height or
x-height on top, descender or baseline at the bottom. On a real scanned
form, a rewritten "DT" -> "DSI" now matches the scanned word's height
and baseline. The engine OCR-edit tests build their words the way a scan
would box them.

* Edit OCR text: sample colours only for invisible blocks, from the current bytes

Session::apply looked up every edited block with a fresh text_blocks pass and
re-serialized the whole document to render the sampling tile. Text blocks are
now cached per page on the editor (keyed on the unmodified document's source
identity, so any edit, undo or save invalidates them), visible blocks skip
sampling entirely, and the tile renders from the document's current bytes.
Adds a visible stretched-text regression test and documents that editing any
invisible text makes it visible over a painted cover.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Edit OCR text: name the cached text-blocks tuple (clippy), rustfmt

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:16:42 -04:00

165 lines
7.3 KiB
Rust

//! Scan & OCR ▸ Recognize text in the real shell (egui_kittest): the dialog, the run and its
//! result (skipped without the OCR models: `cargo xtask models`).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::{AddedText, Edit, Session, export};
use pdfcraft_ui_egui::PdfCraftApp;
/// A one-page PDF that is only a picture of a sentence.
fn scan() -> Vec<u8> {
let mut s = Session::new();
let text = s.create_from_text("t", "Recognize these scanned words").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
s.create_from_images(&[("scan.png".into(), png)]).unwrap().to_vec()
}
#[test]
fn recognize_text_dialog_adds_searchable_text() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.recognize"));
h.run_steps(2);
h.get_by_label("Document language");
h.get_by_label("Output");
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
h.get_by_label("Recognize text").click();
h.run_steps(3);
assert!(h.state().ocr_run.is_none(), "finished");
let toast = h.state().toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.contains("on 1 page"), "{toast}");
let app = h.state();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text"));
}
#[test]
fn recognize_text_in_multiple_files_writes_searchable_copies() {
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let dir = std::env::temp_dir().join(format!("pdfcraft-ocr-ui-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
app.use_files(pdfcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]);
assert!(app.ocr_batch.is_none(), "finished");
let toast = app.toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.ends_with("in 2 files"), "{toast}");
for name in ["one.pdf", "two.pdf"] {
let bytes = std::fs::read(dir.join(name)).unwrap();
assert!(bytes.len() > scan().len(), "an incremental update was appended");
}
}
/// #1028: "Also recognize pages that already have text" reads a picture on a page that has its own
/// text, without adding that text a second time.
#[test]
fn recognize_text_can_read_pictures_on_pages_with_text() {
let hybrid = {
let mut s = Session::new();
let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap();
let (w, h) = {
let p = &s.get(id).unwrap().info.pages[0];
(p.width as f64, p.height as f64)
};
let note =
AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text: note }).unwrap();
s.save_bytes(id).unwrap().to_vec()
};
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("hybrid.pdf", None, hybrid).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.recognize"));
h.run_steps(2);
assert!(h.state().ocr_draft.settings().skip_text_pages, "pages with text are skipped by default");
h.get_by_label("Also recognize pages that already have text").click();
h.run_steps(2);
assert!(!h.state().ocr_draft.settings().skip_text_pages);
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
h.get_by_label("Recognize text").click();
h.run_steps(3);
assert!(h.state().ocr_run.is_none(), "finished");
let toast = h.state().toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.ends_with("on 1 page"), "{toast}");
let app = h.state();
let id = app.active_ids().unwrap().1;
let doc = app.session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Recognize text"));
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
let text = text.text.unwrap().plain_text().to_lowercase();
assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}");
}
/// Edit text & images on a scanned page: an OCR word (invisible text over the scan) opens for
/// editing on a click, stays open when the pointer leaves it, and its editor hides the scanned
/// word instead of showing it through.
#[test]
fn an_ocr_word_opens_for_editing_and_stays_open() {
use pdfcraft_engine::ocr::PlacedWord;
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("scan.pdf", None, scan()).unwrap();
let id = app.views[0].id;
let ph = app.session.get(id).unwrap().info.pages[0].height as f64;
let words = vec![PlacedWord { text: "Recognize".into(), origin: [72.0, ph - 100.0], across: [80.0, 0.0], up: [0.0, 14.0] }];
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
app.set_option("zoom", "100").unwrap();
assert!(app.execute("edit.edit_text"));
app
});
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (r, k) = {
let app = h.state();
let d = app.session.get(app.views[0].id).unwrap();
assert!(d.text_blocks(0).first().is_some_and(|b| b.invisible));
let r = app.views[0].page_screen_rect(0).unwrap();
(r, r.width() / d.info.pages[0].width)
};
// The word, 100 pt from the top of the page.
let p = r.min + egui::vec2(100.0, 93.0) * k;
h.hover_at(p);
h.run_steps(2);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(4);
let opened = |h: &Harness<'_, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| e.text.clone());
assert_eq!(opened(&h).as_deref(), Some("Recognize"));
assert!(h.state().views[0].image_selection.is_none(), "the scan under the word isn't selected");
h.hover_at(r.min + egui::vec2(300.0, 400.0) * k);
h.run_steps(4);
assert_eq!(opened(&h).as_deref(), Some("Recognize"), "moving the pointer away keeps the word open");
}