Files
pdfcraft/crates/ui-egui/tests/ocr.rs
T
88024858c3 M10: Correct recognized text keeps the window's tabs and toolbar and shows the page (ocr.correct-recognized-text) (#6490)
* M10: Correct recognized text keeps the window's tabs and toolbar and shows the page (ocr.correct-recognized-text)

Scan & OCR > Correct recognized text drew the OCR Verify screen before
the tab strip and the mode bar and returned, so it took the whole
window: the tabs, menus and toolbar went away (on macOS the traffic
lights landed on the PAGES caption). Its source pane drew only a
document the screen had recognized itself, so opened on a document
(already searchable or not) it was blank until a run.

The screen now takes the document area under the tab strip and the
mode bar; the command palette shows over it too. Before a run, and
after a review is accepted or rejected, the source pane shows the
document in front at the page it is on. Another mode or the Home tab
puts the screen away, as Exit does; the command brings it back with
its review as it was. A run now shows the first page it reads (the
current page for that scope) instead of page 1.

Fixes #6485

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

* M10: OCR Verify keeps its review when the document is closed and shows a page it read (ocr.correct-recognized-text)

- Closing a document whose OCR review is still open (File > Close, the tab's
  Close, the snapshot bar's Close, Close all) brings the review forward with
  a notice to accept or reject it first, instead of closing the document under it.
- After a run the source pane shows a page that was read, over one skipped for
  its text; a run that read no pages keeps the page it was started on.
- PAGE shows the page's number over the document's page count.
- A cached preview of another document or page is dropped before rendering.
- The re-entry test checks a review and its correction survive another mode
  and Correct recognized text.

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

* M10: OCR Verify holds Quit for an open review, lets a failed Accept be rejected, and survives a worker that cannot start (ocr.correct-recognized-text)

- Quit and closing the window bring a document under OCR review forward
  with its review instead of exiting, as Close all does.
- Reject also throws away a reading whose Accept failed.
- A run or re-read whose worker thread cannot start leaves the screen
  usable and an existing review as it was.
- A page preview that fails to render is not rendered again on every
  repaint, only once the document changes.

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

* M10: the web build's leave warning asks while an OCR review is open (ocr.correct-recognized-text)

A browser reload or a closed tab cannot be held the way Close and Quit are,
so the beforeunload flag the web page reads now also counts an OCR review
that is not yet accepted or rejected, by the same check that holds Close all
and Quit.

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

* M10: keep LF line endings in the files the leave-warning change touched (ocr.correct-recognized-text)

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

* M10: an OCR review holds a deferred close, a run on another document, and the embedding page's leave warning (ocr.correct-recognized-text)

A close that waited for a save checks again for an OCR review started
meanwhile: the reviewed document stays open, and Close all and Quit stop.
Run OCR on another document while a review is held is refused with a
notice, so the review keeps its corrections. The embedding page's
`host_dirty` hears an open review as work at risk, as the reload warning
does.

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

---------

Co-authored-by: mtlcl410 <chuanlin@niar.org.tw>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 00:32:03 +08:00

282 lines
13 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::ocr::{OcrPage, OcrSettings, PlacedWord};
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");
}
}
/// A placed word the review can hold (its box sits where a real one would).
fn placed(text: &str, confidence: f32) -> PlacedWord {
PlacedWord { text: text.into(), origin: [40.0, 40.0], across: [50.0, 0.0], up: [0.0, 12.0], confidence: Some(confidence), source: "ocrs".into() }
}
/// The OCR Verify screen (Scan & OCR ▸ Correct recognized text) in the real shell: every
/// control is live, the keyboard walks the uncertain words, the inspector removes and restores
/// through clicks, and Accept applies the review as one undoable step. The recognition is
/// loaded directly, so this runs without the OCR models.
#[test]
fn ocr_verify_screen_controls_keyboard_and_accept() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
// The screen's controls are all present (no dead controls).
h.get_by_label("Run OCR");
h.get_by_label("Reject");
h.get_by_label("Next uncertain");
h.get_by_label("Previous uncertain");
h.get_by_label("Page verified");
h.get_by_label("Exit");
use pdfcraft_ui_egui::Phase;
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// The guards hold in the real UI: Reject outside ReviewReady is ignored, not acted on.
h.get_by_label("Reject").click();
h.run_steps(2);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// A recognition result, as the worker would deliver it.
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page =
OcrPage { already_on_page: 0, page: 0, words: vec![placed("Helo", 40.0), placed("wrold", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::ReviewReady);
// F8 walks to the Low-band word; the inspector shows it.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, Some((0, 0)));
h.get_by_label("Remove").click();
h.run_steps(2);
assert!(h.state().ocr_verify.is_removed((0, 0)), "Remove strikes the word out");
h.get_by_label("Restore").click();
h.run_steps(2);
assert!(!h.state().ocr_verify.is_removed((0, 0)), "Restore brings it back");
// Ctrl+T marks the page reviewed (the checkbox flips).
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(h.state().ocr_verify.review.verified_pages.contains(&0), "Ctrl+T marks the page verified");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(!h.state().ocr_verify.review.verified_pages.contains(&0));
// Accept applies the whole review as ONE undoable step on the open document.
h.get_by_label("Accept").click();
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle, "a successful accept ends the review");
let app = h.state();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text"));
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("one undoable step"), "{toast}");
}
/// Ctrl+Tab moves the pane focus ring, and F8 with nothing to review is a no-op: the walk
/// never invents a selection. The recognition is loaded directly (no models needed).
#[test]
fn ocr_verify_screen_focus_ring_and_empty_walk() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page = OcrPage { already_on_page: 0, page: 0, words: vec![placed("Fine", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
// Nothing asks for review: F8 selects nothing instead of wrapping into an error.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, None);
// Ctrl+Tab cycles the four panes; four steps come back to the start.
let start = h.state().ocr_verify.focus;
let mut moved = false;
for _ in 0..4 {
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Tab);
h.run_steps(2);
moved |= h.state().ocr_verify.focus != start;
}
assert!(moved, "Ctrl+Tab moves the focus");
assert_eq!(h.state().ocr_verify.focus, start, "the ring returns after the four panes");
}
/// #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],
confidence: None,
source: "ocrs".into(),
}];
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");
}