diff --git a/ROADMAP.md b/ROADMAP.md index 9996f56361..1facab7050 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -231,7 +231,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log - **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged. -- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). Known limits: the cleaning steps and the Verify screen's re-reads run on the page. Estimates unchanged. +- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged. - **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 8f68ed782a..5fdc5a456c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -645,6 +645,11 @@ impl eframe::App for PdfCraftApp { if self.ocr_verify.phase == workflows::ocr_verify::Phase::Running { workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll); } + // A run or re-read asked for while the web build downloaded the models starts when they land. + #[cfg(target_arch = "wasm32")] + if self.ocr_verify.awaiting_models.is_some() { + workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll_models); + } // So may a page or region re-read's worker. if self.ocr_verify.reread.is_some() { workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll_reread); diff --git a/crates/ui-egui/src/workflows/ocr_ui.rs b/crates/ui-egui/src/workflows/ocr_ui.rs index 58aafc846b..756da56177 100644 --- a/crates/ui-egui/src/workflows/ocr_ui.rs +++ b/crates/ui-egui/src/workflows/ocr_ui.rs @@ -779,7 +779,7 @@ impl PdfCraftApp { /// The web build downloads the models the first time they're needed: while `engine` can't /// run for want of them, start (or retry) the download, say so and return `true`. #[cfg(target_arch = "wasm32")] - fn web_models_missing(&mut self, engine: EngineChoice) -> bool { + pub(crate) fn web_models_missing(&mut self, engine: EngineChoice) -> bool { if pdfcraft_engine::ocr::engine_unavailable_reason(engine).is_none() { return false; } diff --git a/crates/ui-egui/src/workflows/ocr_verify.rs b/crates/ui-egui/src/workflows/ocr_verify.rs index 4054339de4..281e57abb9 100644 --- a/crates/ui-egui/src/workflows/ocr_verify.rs +++ b/crates/ui-egui/src/workflows/ocr_verify.rs @@ -208,6 +208,15 @@ pub struct Reread { pub(crate) progress: Arc>, } +/// What the Verify screen was asked to read while the web build was still downloading the +/// models ([`OcrVerify::models_landed`] starts it once they are in). +#[derive(Clone, Copy, Debug, PartialEq)] +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) enum AwaitingModels { + Run, + Reread { page: usize, region: Option<[f32; 4]> }, +} + /// A re-read's progress: the replacement words once the worker is done, and a cancel request. /// A cancelled re-read is discarded when it lands: the page's words stay as they were. #[derive(Default)] @@ -243,6 +252,8 @@ pub struct OcrVerify { /// A page or region re-read running on a worker thread: the screen stays live (the review /// edits and navigation work) and the reading is applied by [`Self::poll_reread`]. pub reread: Option, + /// A run or re-read waiting for the web build's model download. + pub(crate) awaiting_models: Option, /// The correction text field's current contents. pub correction: String, /// Where the per-language dictionary files live (`None`: off, e.g. the web build). @@ -290,6 +301,7 @@ impl Default for OcrVerify { cancelled: false, progress: None, reread: None, + awaiting_models: None, correction: String::new(), dictionary_dir: None, dictionary_language: "en".into(), @@ -851,6 +863,11 @@ impl OcrVerify { } let Some(doc) = self.doc else { return }; let settings = self.reread_settings(&app.ocr_draft); + #[cfg(target_arch = "wasm32")] + if app.web_models_missing(settings.engine) { + self.wait_for_models(AwaitingModels::Reread { page, region }); + return; + } if let Some(region) = region { let Some(info) = app.session.get(doc).and_then(|d| d.info.pages.get(page)) else { app.notify_error("the page is gone"); @@ -907,6 +924,51 @@ impl OcrVerify { self.reread = Some(Reread { page, region, progress }); } + /// The web build is downloading the models: remember what was asked for and say so; it + /// starts when they land ([`Self::poll_models`]). Before, the read ran at once and failed + /// with the desktop's "set PDFCRAFT_MODELS" message, which means nothing in a browser. + #[cfg(target_arch = "wasm32")] + fn wait_for_models(&mut self, what: AwaitingModels) { + self.awaiting_models = Some(what); + self.message = tl!("Downloading the text recognition models…").to_string(); + } + + /// The web build: start what waited for the models once the download is over. + #[cfg(target_arch = "wasm32")] + pub(crate) fn poll_models(&mut self, app: &mut PdfCraftApp) { + if self.awaiting_models.is_none() { + return; + } + use pdfcraft_engine::ocr::{EngineChoice, engine_unavailable_reason}; + if engine_unavailable_reason(EngineChoice::Ocrs).is_none() { + // The models are in. An engine the browser can never run (Tesseract) says why + // rather than waiting for ever. + let outcome = engine_unavailable_reason(app.ocr_draft.engine).map_or(Ok(()), Err); + self.models_landed(app, outcome); + } else if let crate::ocr_web::State::Failed(e) = crate::ocr_web::state() { + self.models_landed(app, Err(e)); + } + } + + /// The model download is over: run what waited for it, or say why it can't. Nothing runs + /// once the screen is closed. + #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] + pub(crate) fn models_landed(&mut self, app: &mut PdfCraftApp, outcome: Result<(), String>) { + let Some(what) = self.awaiting_models.take() else { return }; + self.message.clear(); + if let Err(e) = outcome { + self.message = crate::i18n::fmt(tl!("Couldn't download the text recognition models: {e}"), &[("e", &e)]); + return; + } + if !self.open { + return; + } + match what { + AwaitingModels::Run => self.start_run(app), + AwaitingModels::Reread { page, region } => self.start_reread(app, page, region), + } + } + /// Ask a running re-read to stop and be discarded: when the worker lands, its reading is /// thrown away and the page's words stay as they were. pub fn cancel_reread(&mut self) { @@ -1086,6 +1148,11 @@ impl OcrVerify { super::ocr_ui::OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(), }; let settings = d.settings(); + #[cfg(target_arch = "wasm32")] + if app.web_models_missing(settings.engine) { + self.wait_for_models(AwaitingModels::Run); + return; + } let Some(job) = app.session.ocr_job(id, &pages, settings.clone()) else { return }; self.dictionary_language = job.settings.language.clone(); let progress = Arc::new(Mutex::new(ocr_ui::OcrProgress { total: job.pages.len(), ..Default::default() })); @@ -2437,6 +2504,57 @@ mod tests { assert!(!toast(&mut app).is_empty(), "the failure is said"); } + /// The web build: a Run OCR asked for while the models download runs once they land, rather + /// than failing at once with the desktop's "set PDFCRAFT_MODELS" message. + #[test] + fn a_run_that_waited_for_the_models_starts_when_they_land() { + let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]); + app.ocr_verify.open = true; + app.ocr_verify.awaiting_models = Some(AwaitingModels::Run); + with_state(&mut app, |v, app| v.models_landed(app, Ok(()))); + assert_eq!(app.ocr_verify.awaiting_models, None); + // The run is inline here: it either read the blank page (no words) or failed for want + // of models on this machine. Either way the old review was replaced by a run. + let ran = app.ocr_verify.phase == Phase::RecoverableError || app.ocr_verify.page(0).is_some_and(|p| p.words.is_empty()); + assert!(ran, "the run started: {:?} {}", app.ocr_verify.phase, app.ocr_verify.message); + } + + /// The same for a page or region re-read. + #[test] + fn a_reread_that_waited_for_the_models_starts_when_they_land() { + let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]); + app.ocr_verify.open = true; + app.ocr_verify.awaiting_models = Some(AwaitingModels::Reread { page: 0, region: None }); + with_state(&mut app, |v, app| v.models_landed(app, Ok(()))); + assert_eq!(app.ocr_verify.awaiting_models, None); + assert!(app.ocr_verify.reread.is_some(), "the re-read started"); + } + + /// A failed download is said in the status line, and nothing runs or changes. + #[test] + fn a_failed_model_download_is_said_and_nothing_runs() { + let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]); + app.ocr_verify.open = true; + app.ocr_verify.awaiting_models = Some(AwaitingModels::Run); + with_state(&mut app, |v, app| v.models_landed(app, Err("models/text-detection.onnx: HTTP 404".into()))); + assert_eq!(app.ocr_verify.awaiting_models, None); + assert!(app.ocr_verify.message.contains("Couldn't download") && app.ocr_verify.message.contains("HTTP 404"), "{}", app.ocr_verify.message); + assert_eq!(app.ocr_verify.phase, Phase::ReviewReady); + assert_eq!(app.ocr_verify.page(0).expect("page").words.len(), 1, "the words are untouched"); + } + + /// Closing the screen while the models download drops what waited: nothing runs behind it. + #[test] + fn nothing_waiting_for_the_models_runs_once_the_screen_is_closed() { + let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]); + app.ocr_verify.open = false; + app.ocr_verify.awaiting_models = Some(AwaitingModels::Reread { page: 0, region: None }); + with_state(&mut app, |v, app| v.models_landed(app, Ok(()))); + assert_eq!(app.ocr_verify.awaiting_models, None); + assert!(app.ocr_verify.reread.is_none()); + assert_eq!(app.ocr_verify.page(0).expect("page").words.len(), 1); + } + /// A re-read that finds nothing is a typed failure: the words stay, the review stays. #[test] fn a_reread_that_finds_nothing_keeps_the_words() { diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index d909f21912..83fb9237e4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6947,8 +6947,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = [] -evidence = ["crates/ocr/src/lib.rs::reads_rendered_text_with_models_from_bytes", "crates/ocr/src/lib.rs::models_from_bad_bytes_are_an_error", "crates/ocr/src/lib.rs::lines_survive_json", "crates/ocr/src/lib.rs::malformed_json_lines_are_an_error", "crates/engine/src/ocr.rs::an_installed_recognizer_is_available_and_handed_out", "crates/engine/src/ocr.rs::reading_page_by_page_matches_a_run", "crates/engine/src/tests.rs::reading_a_page_at_a_time_finds_the_same_words", "crates/ui-egui/src/workflows/ocr_ui.rs::a_scan_found_without_the_models_is_read_once_they_load", "xtask/src/web.rs::worker_files_get_content_hashed_names_that_point_at_each_other", "crates/ui-egui/src/workflows/ocr_route.rs::answers_go_to_the_run_that_sent_the_page_in_either_start_order", "crates/ui-egui/src/workflows/ocr_route.rs::a_cancelled_run_lets_the_other_and_the_next_run_finish"] -notes = "The web zip carries the models in models/ beside index.html (packaging/web/package.sh); Recognize Text downloads them on first use (about 12 MB, with a spinner, an error and a retry button). Pages are rendered on the page and read in Web Workers (apps/pdfcraft-ocr-worker; up to two, one core left to the page; idle workers close after a minute), so the page keeps responding; where workers can't start the page reads them itself. Recognize Text and Recognize Text in Multiple Files can run at once: each worker's answer goes back to the run that sent the page (crates/ui-egui/src/workflows/ocr_route.rs). Checked by hand in a browser on a 4-page synthetic scan (2026-10-12). Built with WebAssembly SIMD (2.4× faster than scalar in wasmtime). The page renders, crops and cleans each page (the #977 options) and only the engine runs in a worker, so a cleaning step slows the page while it runs; ocrs only (no Tesseract on the web), and the Verify screen re-reads a page or region on the page itself. Missing: no automated browser test yet; a wasm page reads about 17× slower than native; each worker holds about 600 MB while reading; no download progress or offline cache beyond the browser's HTTP cache." +evidence = ["crates/ocr/src/lib.rs::reads_rendered_text_with_models_from_bytes", "crates/ocr/src/lib.rs::models_from_bad_bytes_are_an_error", "crates/ocr/src/lib.rs::lines_survive_json", "crates/ocr/src/lib.rs::malformed_json_lines_are_an_error", "crates/engine/src/ocr.rs::an_installed_recognizer_is_available_and_handed_out", "crates/engine/src/ocr.rs::reading_page_by_page_matches_a_run", "crates/ui-egui/src/workflows/ocr_verify.rs::a_run_that_waited_for_the_models_starts_when_they_land", "crates/ui-egui/src/workflows/ocr_verify.rs::a_failed_model_download_is_said_and_nothing_runs", "crates/engine/src/tests.rs::reading_a_page_at_a_time_finds_the_same_words", "crates/ui-egui/src/workflows/ocr_ui.rs::a_scan_found_without_the_models_is_read_once_they_load", "xtask/src/web.rs::worker_files_get_content_hashed_names_that_point_at_each_other", "crates/ui-egui/src/workflows/ocr_route.rs::answers_go_to_the_run_that_sent_the_page_in_either_start_order", "crates/ui-egui/src/workflows/ocr_route.rs::a_cancelled_run_lets_the_other_and_the_next_run_finish"] +notes = "The web zip carries the models in models/ beside index.html (packaging/web/package.sh); Recognize Text downloads them on first use (about 12 MB, with a spinner, an error and a retry button); the Verify screen starts the same download and runs once it lands. Pages are rendered on the page and read in Web Workers (apps/pdfcraft-ocr-worker; up to two, one core left to the page; idle workers close after a minute), so the page keeps responding; where workers can't start the page reads them itself. Recognize Text and Recognize Text in Multiple Files can run at once: each worker's answer goes back to the run that sent the page (crates/ui-egui/src/workflows/ocr_route.rs). Checked by hand in a browser on a 4-page synthetic scan (2026-10-12), and after the port onto #977 on synthetic and real scanner scans, with word counts matching desktop (2026-11-07). Built with WebAssembly SIMD (2.4× faster than scalar in wasmtime). The page renders, crops and cleans each page (the #977 options) and only the engine runs in a worker, so a cleaning step slows the page while it runs; ocrs only (no Tesseract on the web), and the Verify screen's Run OCR and its page or region re-reads read on the page itself, not in a worker. Missing: no automated browser test yet; a wasm page reads about 17× slower than native; each worker holds about 600 MB while reading; no download progress or offline cache beyond the browser's HTTP cache." [[feature]] id = "ocr.scanner-acquire"