mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-11 00:41:21 +00:00
M10: Recognize Text in the browser, read in Web Workers (ocr.ocr-web)
The web build downloads the OCR models the first time Recognize Text (or Edit on a scanned page) needs them, installs the engine, and reads pages in up to two Web Workers (apps/pdfcraft-ocr-worker) built with WebAssembly SIMD, so the page keeps responding. The page prepares each page and sends its image to an idle worker, which runs ocrs and sends the lines back. Where no worker can start, the page reads with the installed engine. Answers go back to the run that sent the page (ocr_route), so a single run and a batch can overlap. Recognize Text in Multiple Files on the web keeps main's skipped-files bucket, low-confidence notes and cancel between files. Files chosen before the models are in wait for them. A scan Edit finds while no engine can read it waits for the models instead of counting as no scan. Trunk builds the worker beside the app; `cargo xtask web-worker-link` hashes its files and names it in index.html. The web zip ships the models in models/.
This commit is contained in:
1 parent
0bfb50a853
commit
a10906e991
17 files changed
+1224
-28
No files matched your search
@@ -1,2 +1,7 @@
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
|
||||
# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR
|
||||
# engine's kernels (rten-simd) and other vectorised code only use it when compiled in.
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["-C", "target-feature=+simd128"]
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "pdfcraft-ocr-worker"
|
||||
description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-ocr = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] }
|
||||
@@ -0,0 +1,101 @@
|
||||
//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't
|
||||
//! stop the page responding and several pages are read at once. Trunk builds it alongside
|
||||
//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers
|
||||
//! (crates/ui-egui/src/workflows/ocr_ui.rs).
|
||||
//!
|
||||
//! Messages are plain objects with a `type`:
|
||||
//!
|
||||
//! - `ready` (to the page): the worker is listening; send the models now.
|
||||
//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model
|
||||
//! file. Answered with `loaded`, or `failed` with an `error`.
|
||||
//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page
|
||||
//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as
|
||||
//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
let Ok(scope) = js_sys::global().dyn_into::<web_sys::DedicatedWorkerGlobalScope>() else {
|
||||
return;
|
||||
};
|
||||
let reply_to = scope.clone();
|
||||
let mut ocr = None;
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
|
||||
let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data()));
|
||||
});
|
||||
scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
// The handler lives as long as the worker.
|
||||
on_message.forget();
|
||||
let _ = scope.post_message(&worker::message(&[("type", "ready".into())]));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web");
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod worker {
|
||||
use pdfcraft_ocr::Ocr;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
|
||||
/// The models are about 12 MB together.
|
||||
const MAX_MODEL_BYTES: u32 = 64 << 20;
|
||||
/// The renderer keeps pages far smaller; anything larger isn't a page.
|
||||
const MAX_PIXEL_BYTES: u64 = 1 << 30;
|
||||
|
||||
pub fn message(fields: &[(&str, JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in fields {
|
||||
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// Answer one message from the page.
|
||||
pub fn handle(ocr: &mut Option<Ocr>, data: &JsValue) -> JsValue {
|
||||
let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let id = get("id");
|
||||
let reply = match get("type").as_string().as_deref() {
|
||||
Some("models") => load(&get("detection"), &get("recognition")).map(|o| {
|
||||
*ocr = Some(o);
|
||||
message(&[("type", "loaded".into())])
|
||||
}),
|
||||
Some("page") => match ocr.as_ref() {
|
||||
None => Err("the text recognition models aren't loaded".into()),
|
||||
Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels"))
|
||||
.map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])),
|
||||
},
|
||||
_ => Err("unknown message".into()),
|
||||
};
|
||||
reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())]))
|
||||
}
|
||||
|
||||
fn bytes(v: &JsValue, max: u64) -> Result<Vec<u8>, String> {
|
||||
let a = v.dyn_ref::<js_sys::Uint8Array>().ok_or("expected a Uint8Array")?;
|
||||
if u64::from(a.length()) > max {
|
||||
return Err(format!("{} bytes is too large", a.length()));
|
||||
}
|
||||
Ok(a.to_vec())
|
||||
}
|
||||
|
||||
fn load(detection: &JsValue, recognition: &JsValue) -> Result<Ocr, String> {
|
||||
let max = u64::from(MAX_MODEL_BYTES);
|
||||
Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result<String, String> {
|
||||
let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (Some(w), Some(h)) = (side(width), side(height)) else {
|
||||
return Err("the page size is not a positive whole number".into());
|
||||
};
|
||||
let px = bytes(pixels, MAX_PIXEL_BYTES)?;
|
||||
if px.len() as u64 != u64::from(w) * u64::from(h) * 4 {
|
||||
return Err("the pixels don't match the page size".into());
|
||||
}
|
||||
ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,10 @@ filehash = true
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8766
|
||||
|
||||
# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the
|
||||
# worker for the page (xtask/src/web.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-worker-link"]
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PdfCraft</title>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||
<!-- Recognises text off the page's thread (crates/ui-egui/src/workflows/ocr_web.rs). -->
|
||||
<link data-trunk rel="rust" href="../pdfcraft-ocr-worker/Cargo.toml" data-type="worker" data-loader-shim data-bindgen-target="web" data-wasm-opt="3" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #f1f1f3; }
|
||||
canvas { width: 100%; height: 100%; display: block; outline: none; }
|
||||
|
||||
@@ -88,7 +88,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator", "RequestCache", "RequestInit", "Response", "Worker", "WorkerOptions", "WorkerType", "MessageEvent"] }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -203,6 +203,10 @@ impl PdfCraftApp {
|
||||
ocr_draft: ocr_ui::OcrDraft::default(),
|
||||
ocr_run: None,
|
||||
ocr_batch: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_batch: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_waiting: None,
|
||||
ocr_verify: ocr_verify::OcrVerify::default(),
|
||||
run_inline: false,
|
||||
custom_actions: Vec::new(),
|
||||
|
||||
@@ -636,6 +636,10 @@ pub struct DocView {
|
||||
/// Pages Edit looked at and found not to be a scan, with the document's edit generation then:
|
||||
/// they are looked at again only after the document changes (an image added, say).
|
||||
pub(crate) auto_ocr_skipped: std::collections::HashMap<usize, u64>,
|
||||
/// Scans Edit found while no engine could read them, with the document's edit generation
|
||||
/// then: they wait for one (the web build downloads the models when first needed), and are
|
||||
/// read once the models are in (see `PdfCraftApp::ocr_models_loaded`) or the document changes.
|
||||
pub(crate) auto_ocr_awaiting_models: std::collections::HashMap<usize, u64>,
|
||||
/// Turns wheel input into page turns in single-page view.
|
||||
wheel: crate::wheel_pager::WheelPager,
|
||||
/// The document owns the full coarse-wheel distance, including its acceleration.
|
||||
@@ -1047,6 +1051,7 @@ impl DocView {
|
||||
shown: false,
|
||||
auto_ocr_tried: Default::default(),
|
||||
auto_ocr_skipped: Default::default(),
|
||||
auto_ocr_awaiting_models: Default::default(),
|
||||
wheel: Default::default(),
|
||||
wheel_motion: Default::default(),
|
||||
wheel_frame: None,
|
||||
|
||||
@@ -181,8 +181,12 @@ use workflows::create_ui;
|
||||
use workflows::export_ui;
|
||||
use workflows::files;
|
||||
use workflows::js_ui;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_route;
|
||||
use workflows::ocr_ui;
|
||||
use workflows::ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_web;
|
||||
use workflows::optimize_ui;
|
||||
use workflows::print_ui;
|
||||
use workflows::scan_clean_ui;
|
||||
@@ -518,6 +522,13 @@ pub struct PdfCraftApp {
|
||||
pub ocr_draft: ocr_ui::OcrDraft,
|
||||
pub ocr_run: Option<ocr_ui::OcrRun>,
|
||||
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
|
||||
/// The web build's files being read in Web Workers (progress in `ocr_batch`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_batch: Option<ocr_web::Batch>,
|
||||
/// The web build's files chosen before the text recognition models were downloaded, with
|
||||
/// the settings they were chosen with: read once the models are ready.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_waiting: Option<ocr_ui::WaitingFiles>,
|
||||
/// Scan & OCR ▸ Correct recognized text: the OCR Verify screen (its state, never persisted).
|
||||
pub ocr_verify: ocr_verify::OcrVerify,
|
||||
/// Background jobs (OCR, actions, listing pinned folders) run inline instead (tests).
|
||||
|
||||
@@ -8,8 +8,12 @@ pub(super) mod create_ui;
|
||||
pub(super) mod export_ui;
|
||||
pub(super) mod files;
|
||||
pub(super) mod js_ui;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
pub(super) mod ocr_route;
|
||||
pub(super) mod ocr_ui;
|
||||
pub(super) mod ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) mod ocr_web;
|
||||
pub(super) mod optimize_ui;
|
||||
pub(super) mod print_ui;
|
||||
pub(super) mod scan_clean_ui;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Which run a page read by a Web Worker belongs to (the web build's OCR, `ocr_web`).
|
||||
//!
|
||||
//! Recognize Text and Recognize Text in Multiple Files can run at once and share the workers, so
|
||||
//! each answer goes back to the run that sent its page: [`Router::send`] notes the run's
|
||||
//! [`Inbox`] under the page's id and [`Router::deliver`] puts the answer there. A run that has
|
||||
//! been dropped (cancelled or finished) gets nothing, and its answers are let go.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::{Rc, Weak};
|
||||
|
||||
/// Answers by page id.
|
||||
type Answers<T> = RefCell<Vec<(u64, T)>>;
|
||||
|
||||
/// Where one run's answers arrive.
|
||||
pub struct Inbox<T>(Rc<Answers<T>>);
|
||||
|
||||
impl<T> Default for Inbox<T> {
|
||||
fn default() -> Self {
|
||||
Inbox(Rc::new(RefCell::new(Vec::new())))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Inbox<T> {
|
||||
/// The answers that have arrived since the last call.
|
||||
pub fn take(&self) -> Vec<(u64, T)> {
|
||||
self.0.try_borrow_mut().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Page ids and the run each one belongs to.
|
||||
pub struct Router<T> {
|
||||
next_id: u64,
|
||||
owners: HashMap<u64, Weak<Answers<T>>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Router<T> {
|
||||
fn default() -> Self {
|
||||
Router { next_id: 0, owners: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Router<T> {
|
||||
/// Send a page for `inbox`'s run: `post(id)` hands it to a worker under a fresh id (ids are
|
||||
/// never reused, so a late answer can't be mistaken for a newer page). The id, or `None` when
|
||||
/// `post` couldn't send it.
|
||||
pub fn send(&mut self, inbox: &Inbox<T>, post: impl FnOnce(u64) -> bool) -> Option<u64> {
|
||||
let id = self.next_id;
|
||||
if !post(id) {
|
||||
return None;
|
||||
}
|
||||
self.next_id = self.next_id.wrapping_add(1);
|
||||
self.owners.insert(id, Rc::downgrade(&inbox.0));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The answer for page `id`: to its run's inbox, or dropped when that run is gone or the id
|
||||
/// was never sent.
|
||||
pub fn deliver(&mut self, id: u64, answer: T) {
|
||||
let Some(inbox) = self.owners.remove(&id).and_then(|w| w.upgrade()) else { return };
|
||||
if let Ok(mut v) = inbox.try_borrow_mut() {
|
||||
v.push((id, answer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Single-file and multiple-file OCR share the workers: whichever run started first, and
|
||||
/// whichever run collects first, each gets exactly its own pages back.
|
||||
#[test]
|
||||
fn answers_go_to_the_run_that_sent_the_page_in_either_start_order() {
|
||||
for single_first in [true, false] {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let (single, batch) = (Inbox::default(), Inbox::default());
|
||||
let (s, b) = if single_first {
|
||||
let s = r.send(&single, |_| true).expect("sent");
|
||||
(s, r.send(&batch, |_| true).expect("sent"))
|
||||
} else {
|
||||
let b = r.send(&batch, |_| true).expect("sent");
|
||||
(r.send(&single, |_| true).expect("sent"), b)
|
||||
};
|
||||
// Both answers arrive in one drain of the workers' events, the batch's first.
|
||||
r.deliver(b, "batch page");
|
||||
r.deliver(s, "single page");
|
||||
// The single run collects first, as pump_web_ocr does; it must not swallow the
|
||||
// batch's answer.
|
||||
assert_eq!(single.take(), vec![(s, "single page")], "single_first: {single_first}");
|
||||
assert_eq!(batch.take(), vec![(b, "batch page")], "single_first: {single_first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled (dropped) run's late answers are let go, the other run still gets its own,
|
||||
/// and a run started afterwards gets its pages.
|
||||
#[test]
|
||||
fn a_cancelled_run_lets_the_other_and_the_next_run_finish() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let (single, batch) = (Inbox::default(), Inbox::default());
|
||||
let s = r.send(&single, |_| true).expect("sent");
|
||||
let b = r.send(&batch, |_| true).expect("sent");
|
||||
drop(single);
|
||||
r.deliver(s, "late");
|
||||
r.deliver(b, "batch page");
|
||||
assert_eq!(batch.take(), vec![(b, "batch page")]);
|
||||
assert!(r.owners.is_empty(), "nothing is kept for a run that's gone");
|
||||
|
||||
let next = Inbox::default();
|
||||
let n = r.send(&next, |_| true).expect("sent");
|
||||
assert_ne!(n, s, "ids are not reused");
|
||||
r.deliver(n, "next page");
|
||||
assert_eq!(next.take(), vec![(n, "next page")]);
|
||||
}
|
||||
|
||||
/// A page that couldn't be posted takes no id and has no owner.
|
||||
#[test]
|
||||
fn a_page_that_could_not_be_sent_is_not_routed() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let inbox = Inbox::default();
|
||||
assert_eq!(r.send(&inbox, |_| false), None);
|
||||
assert!(r.owners.is_empty());
|
||||
let id = r.send(&inbox, |_| true);
|
||||
assert_eq!(id, Some(0), "the id was not used up");
|
||||
}
|
||||
|
||||
/// Answers for ids nobody sent, or sent twice, change nothing.
|
||||
#[test]
|
||||
fn unknown_and_repeated_answers_are_ignored() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let inbox = Inbox::default();
|
||||
let id = r.send(&inbox, |_| true).expect("sent");
|
||||
r.deliver(id + 100, "stray");
|
||||
r.deliver(id, "page");
|
||||
r.deliver(id, "again");
|
||||
assert_eq!(inbox.take(), vec![(id, "page")]);
|
||||
assert!(inbox.take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,10 @@ struct ScanToRead {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Files to recognize, by name, and the settings they were chosen with.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type WaitingFiles = (Vec<(String, Vec<u8>)>, OcrSettings);
|
||||
|
||||
pub struct OcrRun {
|
||||
pub doc: DocId,
|
||||
pub progress: Arc<Mutex<OcrProgress>>,
|
||||
@@ -206,6 +210,9 @@ pub struct OcrRun {
|
||||
/// The dialog promised an editable document: the result goes to the OCR Verify screen
|
||||
/// (whose Accept produces the editable document), not to an invisible layer.
|
||||
pub editable: bool,
|
||||
/// The web build reads the pages in Web Workers, pumped by [`PdfCraftApp::poll_ocr`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub web: Option<crate::ocr_web::Pages>,
|
||||
}
|
||||
|
||||
/// What an Edit-started run needs to know to refuse a result that no longer fits its document.
|
||||
@@ -351,7 +358,36 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
});
|
||||
});
|
||||
keep_asking_while_probing(ui.ctx());
|
||||
if let Some(why) = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine) {
|
||||
// The web build has no model files: it downloads the models the first time they're needed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let downloading = d.engine != EngineChoice::Tesseract && pdfcraft_engine::ocr::engine_unavailable_reason(EngineChoice::Ocrs).is_some();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let downloading = false;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if downloading {
|
||||
match crate::ocr_web::state() {
|
||||
crate::ocr_web::State::Failed(e) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let msg = crate::i18n::fmt(tl!("Couldn't download the text recognition models: {e}"), &[("e", &e)]);
|
||||
ui.label(egui::RichText::new(msg).small().color(t.text_muted));
|
||||
if widgets::pill_button(ui, tl!("Download"), false).clicked() {
|
||||
crate::ocr_web::start(ui.ctx().clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
state => {
|
||||
if state == crate::ocr_web::State::NotStarted {
|
||||
crate::ocr_web::start(ui.ctx().clone());
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(egui::RichText::new(tl!("Downloading the text recognition models…")).small().color(t.text_muted));
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let Some(why) = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine).filter(|_| !downloading) {
|
||||
// Said in the interface's language: the built-in engine's missing models get the
|
||||
// notice users know; Tesseract's reasons are catalog keys of their own.
|
||||
let text = match d.engine {
|
||||
@@ -390,6 +426,10 @@ impl PdfCraftApp {
|
||||
self.notify(why);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.web_models_missing(self.ocr_draft.engine) {
|
||||
return;
|
||||
}
|
||||
let d = &self.ocr_draft;
|
||||
let pages: Vec<usize> = match d.pages {
|
||||
OcrPages::All => Vec::new(),
|
||||
@@ -408,12 +448,13 @@ impl PdfCraftApp {
|
||||
/// boxes over it do not count as text, but real page text does, and such a page (a flyer with a
|
||||
/// background picture) is left alone, and is judged again only when the document changes. It
|
||||
/// is one undoable step, "Recognize text", and is not run again for that page after Undo.
|
||||
/// Without an engine the page waits for one (see [`Self::await_ocr_models`]).
|
||||
pub(crate) fn auto_ocr_scanned_page(&mut self) {
|
||||
let Some(scan) = self.scan_to_read() else { return };
|
||||
// the engine this run will use, not always the built-in one
|
||||
let mut settings = self.ocr_draft.settings();
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(settings.engine).is_some() {
|
||||
self.views[scan.view].auto_ocr_skipped.insert(scan.page, scan.generation);
|
||||
self.await_ocr_models(&scan);
|
||||
return;
|
||||
}
|
||||
self.views[scan.view].auto_ocr_tried.insert(scan.page);
|
||||
@@ -425,6 +466,30 @@ impl PdfCraftApp {
|
||||
self.start_ocr_pages(scan.doc, &[scan.page], settings, true);
|
||||
}
|
||||
|
||||
/// Edit found a scan but no engine can read it. The page is set aside until one can (rather
|
||||
/// than judged again every frame), and the web build starts downloading the models, as
|
||||
/// Recognize Text does. After a failed download it doesn't try again by itself: Recognize
|
||||
/// Text does, and the page is read once the models are in.
|
||||
fn await_ocr_models(&mut self, scan: &ScanToRead) {
|
||||
self.views[scan.view].auto_ocr_awaiting_models.insert(scan.page, scan.generation);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if crate::ocr_web::state() == crate::ocr_web::State::NotStarted
|
||||
&& let Some(ctx) = self.ctx.clone()
|
||||
{
|
||||
crate::ocr_web::start(ctx);
|
||||
self.notify(tl!("Downloading the text recognition models…"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCR models have just loaded: the scans Edit set aside for want of them may be read.
|
||||
/// Only the web build loads them while running.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) fn ocr_models_loaded(&mut self) {
|
||||
for view in &mut self.views {
|
||||
view.auto_ocr_awaiting_models.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The page Edit should read now, if there is one: Edit is on outside Read mode with no dialog
|
||||
/// open, nothing else is being recognized, the document may be changed, the page in view has
|
||||
/// not been read before (nor been found no scan since the document last changed), and an image
|
||||
@@ -438,7 +503,7 @@ impl PdfCraftApp {
|
||||
// Not in Read mode (which leaves the Edit tool picked but is not editing) and not beneath a
|
||||
// dialog or prompt: nothing may change the document under those. It is tried again once
|
||||
// they are gone.
|
||||
if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() {
|
||||
if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch_running() {
|
||||
return None;
|
||||
}
|
||||
let (view, doc) = self.active_ids()?;
|
||||
@@ -453,7 +518,9 @@ impl PdfCraftApp {
|
||||
// A page found not to be a scan is judged again only once the document has changed, not
|
||||
// every frame (listing its images parses the page).
|
||||
let generation = document.edit_generation();
|
||||
if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) {
|
||||
if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation)
|
||||
|| self.views[view].auto_ocr_awaiting_models.get(&page) == Some(&generation)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = document.info.pages.get(page)?;
|
||||
@@ -481,6 +548,29 @@ impl PdfCraftApp {
|
||||
let guard = job.guard.clone();
|
||||
let editable = settings.output_mode == OutputMode::EditableText;
|
||||
let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() }));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let web = Some(crate::ocr_web::Pages::new(job.steps()));
|
||||
self.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: auto, editable, web });
|
||||
self.poll_ocr();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.run_ocr_job(id, job, settings, progress, guard, auto, editable);
|
||||
}
|
||||
|
||||
/// Read the pages on a worker thread (inline in tests).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_ocr_job(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
job: pdfcraft_engine::ocr::OcrJob,
|
||||
settings: OcrSettings,
|
||||
progress: Arc<Mutex<OcrProgress>>,
|
||||
guard: pdfcraft_engine::ocr::OcrGuard,
|
||||
auto: Option<AutoOcr>,
|
||||
editable: bool,
|
||||
) {
|
||||
let p = progress.clone();
|
||||
// A failure is written on the worker thread: in the UI's language.
|
||||
let lang = crate::i18n::current();
|
||||
@@ -500,14 +590,11 @@ impl PdfCraftApp {
|
||||
s.result = Some(result);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr".into()).spawn(work).ok();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: auto, editable });
|
||||
self.poll_ocr();
|
||||
}
|
||||
@@ -515,7 +602,7 @@ impl PdfCraftApp {
|
||||
/// Recognize text in each file, writing the searchable copies into a folder the user picks
|
||||
/// (the export folder override in tests), under the same names.
|
||||
pub fn ocr_files(&mut self, files: Vec<(String, Vec<u8>)>) {
|
||||
if self.ocr_batch.is_some() {
|
||||
if self.ocr_batch_running() {
|
||||
self.notify_tr("Text recognition is already running");
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +624,7 @@ impl PdfCraftApp {
|
||||
/// [`Self::ocr_files`] once the folder for the searchable files is known.
|
||||
fn ocr_files_into(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings, #[cfg(not(target_arch = "wasm32"))] dir: std::path::PathBuf) {
|
||||
// Another batch may have started while the folder picker was open.
|
||||
if self.ocr_batch.is_some() {
|
||||
if self.ocr_batch_running() {
|
||||
self.notify_tr("Text recognition is already running");
|
||||
return;
|
||||
}
|
||||
@@ -576,6 +663,67 @@ impl PdfCraftApp {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// The files wait for the models rather than having to be chosen again.
|
||||
if self.web_models_missing(settings.engine) {
|
||||
self.ocr_web_waiting = Some((files, settings));
|
||||
return;
|
||||
}
|
||||
self.start_web_batch(files, settings);
|
||||
self.poll_ocr();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.run_ocr_files(files, settings, dir);
|
||||
}
|
||||
|
||||
/// Whether Recognize Text in Multiple Files is running (on the web, or waiting for the
|
||||
/// models to download).
|
||||
fn ocr_batch_running(&self) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.ocr_web_waiting.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.ocr_batch.is_some()
|
||||
}
|
||||
|
||||
/// The web build: read the files in Web Workers (advanced by [`Self::poll_ocr`]).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn start_web_batch(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings) {
|
||||
let progress = Arc::new(Mutex::new(BatchProgress { total: files.len(), ..Default::default() }));
|
||||
self.ocr_web_batch = Some(crate::ocr_web::Batch::new(files, settings));
|
||||
self.ocr_batch = Some(progress);
|
||||
}
|
||||
|
||||
/// The web build: start the files that waited for the models once they are loaded, or give
|
||||
/// them up with the reason when the download failed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn start_waiting_web_batch(&mut self) {
|
||||
let Some((_, settings)) = &self.ocr_web_waiting else { return };
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(settings.engine).is_none() {
|
||||
if let Some((files, settings)) = self.ocr_web_waiting.take() {
|
||||
self.start_web_batch(files, settings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match crate::ocr_web::state() {
|
||||
crate::ocr_web::State::Loading => {}
|
||||
crate::ocr_web::State::Failed(e) => {
|
||||
self.ocr_web_waiting = None;
|
||||
self.notify(failed(&e));
|
||||
}
|
||||
// Not loading and not loaded (nothing has started the download): start it.
|
||||
crate::ocr_web::State::NotStarted => {
|
||||
if let Some(ctx) = self.ctx.clone() {
|
||||
crate::ocr_web::start(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize text in the files on a worker thread (inline in tests).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_ocr_files(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings, dir: std::path::PathBuf) {
|
||||
// The engines are built (and named if they cannot be) before anything starts.
|
||||
let recognizers = match pdfcraft_engine::ocr::recognizers(&settings) {
|
||||
Ok(r) => r,
|
||||
@@ -590,12 +738,9 @@ impl PdfCraftApp {
|
||||
let lang = crate::i18n::current();
|
||||
let work = move || {
|
||||
crate::i18n::set_current(lang);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let write = move |name: &str, r: &pdfcraft_engine::ocr::FileResult| {
|
||||
crate::editing::write_atomically(&dir.join(name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let write = move |name: &str, r: &pdfcraft_engine::ocr::FileResult| crate::editing::download(name, &r.bytes);
|
||||
// A panic in the recognizer still ends the batch with a summary (#3919).
|
||||
let msg = pdfcraft_engine::guard(|| run_batch_files(files, settings, &recognizers, &p, write)).unwrap_or_else(|panic| failed(&panic));
|
||||
if let Ok(mut s) = p.lock() {
|
||||
@@ -603,14 +748,11 @@ impl PdfCraftApp {
|
||||
s.message = Some(msg);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr-files".into()).spawn(work).ok();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.ocr_batch = Some(progress);
|
||||
self.poll_ocr();
|
||||
}
|
||||
@@ -634,8 +776,74 @@ 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 {
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(engine).is_none() {
|
||||
return false;
|
||||
}
|
||||
if crate::ocr_web::state() != crate::ocr_web::State::Loading
|
||||
&& let Some(ctx) = self.ctx.clone()
|
||||
{
|
||||
crate::ocr_web::start(ctx);
|
||||
}
|
||||
self.notify(tl!("Downloading the text recognition models…"));
|
||||
true
|
||||
}
|
||||
|
||||
/// The web build: advance the pages being read in Web Workers.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump_web_ocr(&mut self) {
|
||||
self.start_waiting_web_batch();
|
||||
if self.views.iter().any(|v| !v.auto_ocr_awaiting_models.is_empty())
|
||||
&& pdfcraft_engine::ocr::engine_unavailable_reason(EngineChoice::Ocrs).is_none()
|
||||
{
|
||||
self.ocr_models_loaded();
|
||||
}
|
||||
let Some(ctx) = self.ctx.clone() else { return };
|
||||
let mut busy = false;
|
||||
if let Some(run) = self.ocr_run.as_mut()
|
||||
&& let Some(pages) = run.web.as_mut()
|
||||
{
|
||||
busy = true;
|
||||
let cancel = run.progress.lock().map_or(true, |s| s.cancel);
|
||||
let found = pages.pump(cancel, &ctx);
|
||||
let finished = found.is_some();
|
||||
if let Ok(mut s) = run.progress.lock() {
|
||||
(s.done, s.total) = (pages.done(), pages.total());
|
||||
if let Some(found) = found {
|
||||
s.result = Some(Ok(found));
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
run.web = None;
|
||||
}
|
||||
}
|
||||
if let Some(batch) = self.ocr_web_batch.as_mut() {
|
||||
busy = true;
|
||||
let progress = self.ocr_batch.clone();
|
||||
let cancel = progress.as_ref().is_some_and(|b| b.lock().map_or(true, |s| s.cancel));
|
||||
let finished = batch.pump(cancel, &ctx);
|
||||
if let Some(Ok(mut s)) = progress.as_ref().map(|b| b.lock()) {
|
||||
s.done = batch.done();
|
||||
if finished {
|
||||
s.message = Some(batch_summary(batch.ok, batch.words, batch.skipped, &batch.failed, &batch.notes, batch.not_run));
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
self.ocr_web_batch = None;
|
||||
}
|
||||
}
|
||||
if !busy {
|
||||
crate::ocr_web::close_idle_workers(&ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show progress; apply the result once the worker is done.
|
||||
pub(crate) fn poll_ocr(&mut self) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.pump_web_ocr();
|
||||
if let Some(b) = self.ocr_batch.clone() {
|
||||
let msg = b.lock().map(|mut s| s.message.take().ok_or((s.done, s.total))).map_err(|e| e.to_string());
|
||||
match msg {
|
||||
@@ -774,6 +982,7 @@ impl PdfCraftApp {
|
||||
/// or the web's download), count the buckets, and compose the summary. A file the skip-files
|
||||
/// option passes over is its own bucket and `write` is never called for it. A cancel is asked
|
||||
/// for between files: the file in flight finishes, the rest are counted as not run.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_batch_files(
|
||||
files: Vec<(String, Vec<u8>)>,
|
||||
settings: OcrSettings,
|
||||
@@ -799,14 +1008,8 @@ pub(crate) fn run_batch_files(
|
||||
Ok(()) => {
|
||||
ok += 1;
|
||||
words += res.words();
|
||||
// The batch's low-confidence note: at or under the settings' threshold (60 by
|
||||
// default), on 1-based sorted pages.
|
||||
if let Some((count, pages)) = pdfcraft_engine::ocr::low_confidence_pages(&res.pages, settings.low_confidence_threshold) {
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
notes.push(crate::i18n::fmt(
|
||||
tl!("{name}: {n} low-confidence word(s) on pages {pages}"),
|
||||
&[("name", &name), ("n", &count.to_string()), ("pages", &list)],
|
||||
));
|
||||
if let Some(note) = low_confidence_note(&name, &res, settings.low_confidence_threshold) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("{name}: {e}")),
|
||||
@@ -817,6 +1020,14 @@ pub(crate) fn run_batch_files(
|
||||
batch_summary(ok, words, skipped, &failed, ¬es, not_run)
|
||||
}
|
||||
|
||||
/// The batch's low-confidence note for one file: its words at or under `threshold` (60 by
|
||||
/// default), on 1-based sorted pages; `None` when it has none.
|
||||
pub(crate) fn low_confidence_note(name: &str, res: &pdfcraft_engine::ocr::FileResult, threshold: f32) -> Option<String> {
|
||||
let (count, pages) = pdfcraft_engine::ocr::low_confidence_pages(&res.pages, threshold)?;
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
Some(crate::i18n::fmt(tl!("{name}: {n} low-confidence word(s) on pages {pages}"), &[("name", name), ("n", &count.to_string()), ("pages", &list)]))
|
||||
}
|
||||
|
||||
/// The batch summary: what was read, the skipped files as their own bucket, the failures, the
|
||||
/// low-confidence notes and (on a cancel) how many files were not run.
|
||||
fn batch_summary(ok: usize, words: usize, skipped: usize, failed: &[String], notes: &[String], not_run: Option<usize>) -> String {
|
||||
@@ -848,6 +1059,7 @@ fn failed(e: &str) -> String {
|
||||
|
||||
/// `recognize`, with a panic in it (a damaged model, a page the recognizer can't take) turned into
|
||||
/// the run's error: the run has to end either way, or it refuses every later one (#3919).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn recognize_guarded<T>(recognize: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||
pdfcraft_engine::guard(recognize).unwrap_or_else(|panic| Err(failed(&panic)))
|
||||
}
|
||||
@@ -935,6 +1147,18 @@ mod tests {
|
||||
app.session.apply(id, add).unwrap();
|
||||
assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan");
|
||||
}
|
||||
/// A scan found while no engine could read it waits for one, and is read once the models
|
||||
/// load, though the document has not changed. Before, it was taken for no scan and left unread.
|
||||
#[test]
|
||||
fn a_scan_found_without_the_models_is_read_once_they_load() {
|
||||
let mut app = app_with(scan());
|
||||
let scan = app.scan_to_read().unwrap();
|
||||
app.await_ocr_models(&scan);
|
||||
assert_eq!(found(&mut app), None, "it waits for the models, not judged every frame");
|
||||
app.ocr_models_loaded();
|
||||
assert_eq!(found(&mut app), Some(0), "read once they are in");
|
||||
assert!(app.views[0].auto_ocr_skipped.is_empty(), "never taken for no scan");
|
||||
}
|
||||
/// A finished run Edit started is applied only once Read mode and dialogs are gone; one the user
|
||||
/// asked for is applied at once. The result is made up, so no OCR models are needed.
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Scan & OCR in the browser (wasm32 only).
|
||||
//!
|
||||
//! There are no model files to find: the first time recognition is needed the page downloads
|
||||
//! them from `models/` beside itself (packaging/web/package.sh puts them there) and installs the
|
||||
//! recogniser they load. Pages are then read by Web Workers (apps/pdfcraft-ocr-worker), so the
|
||||
//! page keeps responding and two pages are read at once: the page renders a page, hands its
|
||||
//! pixels to an idle worker and places the lines that come back. Where workers can't run (the
|
||||
//! worker files are missing, the browser refuses them, they never get ready) the page reads the
|
||||
//! pages itself, one per frame.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ocr_route::{Inbox, Router};
|
||||
use pdfcraft_engine::ocr::{
|
||||
DETECTION_MODEL, FileJob, Line, Ocr, OcrImage, OcrPage, OcrSettings, OcrShot, OcrSteps, Opened, RECOGNITION_MODEL, StrategyOutcome,
|
||||
};
|
||||
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
|
||||
/// The models are about 12 MB together; a response far larger isn't one of them.
|
||||
const MAX_MODEL_BYTES: u32 = 64 << 20;
|
||||
/// Each worker holds the models and needs about 600 MB while it reads a page (wasm memory never
|
||||
/// shrinks), so a few at most.
|
||||
const MAX_WORKERS: usize = 2;
|
||||
/// A worker that hasn't loaded the models by then is given up on.
|
||||
const START_TIMEOUT_MS: f64 = 60_000.0;
|
||||
/// Workers with nothing to do are closed after this, giving their memory back.
|
||||
const IDLE_CLOSE_MS: f64 = 60_000.0;
|
||||
/// The `<meta>` naming the worker's loader script (xtask/src/web.rs writes it).
|
||||
const WORKER_META: &str = "meta[name=\"pdfcraft-ocr-worker\"]";
|
||||
|
||||
fn now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
// ---- The models -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
NotStarted,
|
||||
Loading,
|
||||
/// Why the last download failed; [`start`] tries again.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State::NotStarted);
|
||||
|
||||
thread_local! {
|
||||
/// The downloaded model files, kept to hand to each worker.
|
||||
static MODELS: RefCell<Option<[js_sys::Uint8Array; 2]>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn state() -> State {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Download and load the models unless that is already happening; repaint when it's done.
|
||||
pub fn start(ctx: egui::Context) {
|
||||
{
|
||||
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *s == State::Loading {
|
||||
return;
|
||||
}
|
||||
*s = State::Loading;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let next = match load().await {
|
||||
Ok((ocr, files)) => {
|
||||
pdfcraft_engine::ocr::install(ocr);
|
||||
MODELS.with_borrow_mut(|m| *m = Some(files));
|
||||
State::NotStarted
|
||||
}
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = next;
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
async fn load() -> Result<(Ocr, [js_sys::Uint8Array; 2]), String> {
|
||||
let detection = fetch(DETECTION_MODEL).await?;
|
||||
let recognition = fetch(RECOGNITION_MODEL).await?;
|
||||
let ocr = Ocr::from_bytes(detection.to_vec(), recognition.to_vec()).map_err(|e| e.to_string())?;
|
||||
Ok((ocr, [detection, recognition]))
|
||||
}
|
||||
|
||||
/// `models/<name>`, relative to the page, so the site works under any path. The names carry no
|
||||
/// content hash and `Ocr::from_bytes` refuses any file but the expected one, so the browser asks
|
||||
/// the server before using a cached copy (`no-cache`): after a model update a stale copy is
|
||||
/// replaced, not refused until the cache expires.
|
||||
async fn fetch(name: &str) -> Result<js_sys::Uint8Array, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
let url = format!("models/{name}");
|
||||
let failed = |e: JsValue| format!("{url}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")));
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_cache(web_sys::RequestCache::NoCache);
|
||||
let resp = JsFuture::from(window.fetch_with_str_and_init(&url, &init)).await.map_err(failed)?;
|
||||
let resp: web_sys::Response = resp.dyn_into().map_err(failed)?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("{url}: HTTP {}", resp.status()));
|
||||
}
|
||||
let buf = JsFuture::from(resp.array_buffer().map_err(failed)?).await.map_err(failed)?;
|
||||
let bytes = js_sys::Uint8Array::new(&buf);
|
||||
if bytes.length() > MAX_MODEL_BYTES {
|
||||
return Err(format!("{url}: {} bytes is too large for a model", bytes.length()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
// ---- The workers ------------------------------------------------------------------------------
|
||||
|
||||
enum Slot {
|
||||
Starting,
|
||||
Loading,
|
||||
Idle,
|
||||
/// Reading the page sent under this id.
|
||||
Busy(u64),
|
||||
Dead,
|
||||
}
|
||||
|
||||
/// What a worker said (see apps/pdfcraft-ocr-worker for the messages).
|
||||
enum Event {
|
||||
Ready,
|
||||
Loaded,
|
||||
Lines(u64, Result<Vec<Line>, String>),
|
||||
Failed(Option<u64>, String),
|
||||
Died,
|
||||
}
|
||||
|
||||
struct Member {
|
||||
worker: web_sys::Worker,
|
||||
slot: Slot,
|
||||
_on_message: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
_on_error: Closure<dyn FnMut(JsValue)>,
|
||||
}
|
||||
|
||||
struct Pool {
|
||||
members: Vec<Member>,
|
||||
events: Rc<RefCell<Vec<(usize, Event)>>>,
|
||||
started: f64,
|
||||
/// When a page last went to or came back from a worker: the idle time runs from here.
|
||||
last_used: f64,
|
||||
/// Which run each page sent belongs to: single-file and multiple-file OCR share the workers.
|
||||
router: Router<Result<Vec<Line>, String>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static POOL: RefCell<Option<Pool>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// How many workers to start: one core stays with the page.
|
||||
fn worker_count() -> usize {
|
||||
let cores = web_sys::window().map_or(1.0, |w| w.navigator().hardware_concurrency());
|
||||
let cores = if cores.is_finite() && cores >= 1.0 { cores as usize } else { 1 };
|
||||
cores.saturating_sub(1).clamp(1, MAX_WORKERS)
|
||||
}
|
||||
|
||||
fn field(data: &JsValue, key: &str) -> JsValue {
|
||||
js_sys::Reflect::get(data, &key.into()).unwrap_or(JsValue::UNDEFINED)
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, &JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in fields {
|
||||
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
impl Event {
|
||||
fn parse(data: &JsValue) -> Event {
|
||||
let id = field(data, "id").as_f64().filter(|v| v.fract() == 0.0 && *v >= 0.0).map(|v| v as u64);
|
||||
let text = |key: &str| field(data, key).as_string().unwrap_or_default();
|
||||
match field(data, "type").as_string().as_deref() {
|
||||
Some("ready") => Event::Ready,
|
||||
Some("loaded") => Event::Loaded,
|
||||
Some("lines") => match id {
|
||||
Some(id) => Event::Lines(id, pdfcraft_engine::ocr::lines_from_json(&text("lines"), "ocrs").map_err(|e| e.to_string())),
|
||||
None => Event::Failed(None, "a result without its page".into()),
|
||||
},
|
||||
Some("failed") => Event::Failed(id, text("error")),
|
||||
_ => Event::Failed(id, "an unknown message".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// Start the workers. None start (and the page reads by itself) when the page doesn't name
|
||||
/// the worker script or the browser refuses it.
|
||||
fn start(ctx: &egui::Context) -> Pool {
|
||||
let t = now();
|
||||
let mut pool = Pool { members: Vec::new(), events: Rc::new(RefCell::new(Vec::new())), started: t, last_used: t, router: Router::default() };
|
||||
let url = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.query_selector(WORKER_META).ok().flatten())
|
||||
.and_then(|m| m.get_attribute("content"));
|
||||
let Some(url) = url else { return pool };
|
||||
let options = web_sys::WorkerOptions::new();
|
||||
options.set_type(web_sys::WorkerType::Module);
|
||||
for i in 0..worker_count() {
|
||||
let Ok(worker) = web_sys::Worker::new_with_options(&url, &options) else { break };
|
||||
let (events, ctx2) = (pool.events.clone(), ctx.clone());
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
|
||||
events.borrow_mut().push((i, Event::parse(&e.data())));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
let (events, ctx2) = (pool.events.clone(), ctx.clone());
|
||||
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |_| {
|
||||
events.borrow_mut().push((i, Event::Died));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
pool.members.push(Member { worker, slot: Slot::Starting, _on_message: on_message, _on_error: on_error });
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
/// Whether any worker is, or may yet be, able to read pages.
|
||||
fn usable(&self) -> bool {
|
||||
self.members.iter().any(|m| !matches!(m.slot, Slot::Dead))
|
||||
}
|
||||
|
||||
fn idle(&self) -> Option<usize> {
|
||||
self.members.iter().position(|m| matches!(m.slot, Slot::Idle))
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.members.iter().any(|m| matches!(m.slot, Slot::Busy(_)))
|
||||
}
|
||||
|
||||
fn kill(&mut self, i: usize) {
|
||||
if let Some(m) = self.members.get_mut(i) {
|
||||
m.worker.terminate();
|
||||
m.slot = Slot::Dead;
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on what the workers said; the pages they finished go to the inboxes of the runs that
|
||||
/// sent them.
|
||||
fn update(&mut self) {
|
||||
let mut done = Vec::new();
|
||||
let events: Vec<_> = self.events.borrow_mut().drain(..).collect();
|
||||
for (i, event) in events {
|
||||
let Some(m) = self.members.get_mut(i) else { continue };
|
||||
let busy = if let Slot::Busy(id) = m.slot { Some(id) } else { None };
|
||||
match event {
|
||||
Event::Ready if matches!(m.slot, Slot::Starting) => {
|
||||
let sent = MODELS.with_borrow(|files| {
|
||||
let [d, r] = files.as_ref()?;
|
||||
m.worker.post_message(&message(&[("type", &"models".into()), ("detection", d), ("recognition", r)])).ok()
|
||||
});
|
||||
if sent.is_some() {
|
||||
m.slot = Slot::Loading;
|
||||
} else {
|
||||
self.kill(i);
|
||||
}
|
||||
}
|
||||
Event::Loaded if matches!(m.slot, Slot::Loading) => m.slot = Slot::Idle,
|
||||
Event::Lines(id, lines) if busy == Some(id) => {
|
||||
m.slot = Slot::Idle;
|
||||
done.push((id, lines));
|
||||
}
|
||||
Event::Failed(Some(id), e) if busy == Some(id) => {
|
||||
m.slot = Slot::Idle;
|
||||
done.push((id, Err(e)));
|
||||
}
|
||||
// A worker that can't load the models, or fails other than on its page, is out.
|
||||
Event::Failed(..) | Event::Died => {
|
||||
if let Some(id) = busy {
|
||||
done.push((id, Err("the text recognition worker stopped".into())));
|
||||
}
|
||||
self.kill(i);
|
||||
}
|
||||
// Stale (a page from a cancelled run) or out of order: nothing to do.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if now() - self.started > START_TIMEOUT_MS {
|
||||
for i in 0..self.members.len() {
|
||||
if self.members.get(i).is_some_and(|m| matches!(m.slot, Slot::Starting | Slot::Loading)) {
|
||||
self.kill(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The idle time starts once a worker is done with its page, not when it was given it (a
|
||||
// page can take most of a minute to read).
|
||||
if !done.is_empty() {
|
||||
self.last_used = now();
|
||||
}
|
||||
for (id, lines) in done {
|
||||
self.router.deliver(id, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `shot` to worker `i` for the run reading into `inbox`; its id, or `None` if it
|
||||
/// couldn't be sent.
|
||||
fn send(&mut self, i: usize, image: &OcrImage, inbox: &Inbox<Result<Vec<Line>, String>>) -> Option<u64> {
|
||||
let m = self.members.get_mut(i)?;
|
||||
let id = self.router.send(inbox, |id| {
|
||||
let pixels = js_sys::Uint8Array::from(image.rgba());
|
||||
let msg = message(&[
|
||||
("type", &"page".into()),
|
||||
("id", &JsValue::from_f64(id as f64)),
|
||||
("width", &image.width().into()),
|
||||
("height", &image.height().into()),
|
||||
("pixels", &pixels),
|
||||
]);
|
||||
// The pixels move to the worker rather than being copied again.
|
||||
m.worker.post_message_with_transfer(&msg, &js_sys::Array::of1(&pixels.buffer())).is_ok()
|
||||
})?;
|
||||
m.slot = Slot::Busy(id);
|
||||
self.last_used = now();
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pool {
|
||||
fn drop(&mut self) {
|
||||
for m in &self.members {
|
||||
m.worker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the workers once they have been idle a while (called when no recognition is running).
|
||||
/// The page only draws a frame when something happens, so one is asked for when the idle time
|
||||
/// runs out: otherwise the workers' memory stays taken until the next click or key.
|
||||
pub fn close_idle_workers(ctx: &egui::Context) {
|
||||
POOL.with_borrow_mut(|pool| {
|
||||
// Answers still arriving for a cancelled run free their workers (and are dropped).
|
||||
if let Some(p) = pool.as_mut() {
|
||||
p.update();
|
||||
}
|
||||
let Some(p) = pool.as_ref().filter(|p| !p.busy()) else { return };
|
||||
let idle = now() - p.last_used;
|
||||
if idle > IDLE_CLOSE_MS {
|
||||
*pool = None;
|
||||
} else {
|
||||
// `as` saturates (and a NaN becomes 0); a little past the limit, so the check passes.
|
||||
let left = (IDLE_CLOSE_MS - idle).max(0.0) as u64;
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(left.saturating_add(50)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Reading pages ----------------------------------------------------------------------------
|
||||
|
||||
/// One run of [`OcrSteps`] through the workers, advanced a step per frame by [`Pages::pump`].
|
||||
pub struct Pages {
|
||||
steps: OcrSteps,
|
||||
/// Pages with a worker: id, position in the run, the page (its pixels gone to the worker).
|
||||
reading: Vec<(u64, usize, OcrShot)>,
|
||||
/// Where the workers' answers for this run's pages arrive (dropped with the run, so a
|
||||
/// cancelled run's late answers are let go).
|
||||
inbox: Inbox<Result<Vec<Line>, String>>,
|
||||
found: Vec<(usize, OcrPage)>,
|
||||
next: usize,
|
||||
rendered_all: bool,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
pub fn new(steps: OcrSteps) -> Pages {
|
||||
Pages { steps, reading: Vec::new(), inbox: Inbox::default(), found: Vec::new(), next: 0, rendered_all: false }
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.steps.total()
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.found.len()
|
||||
}
|
||||
|
||||
/// Collect what the workers read and hand out the next page (rendering at most one a call,
|
||||
/// so a frame stays short). Returns the pages, in order, once all are read or `cancel` is set
|
||||
/// (the pages read so far; answers still coming are ignored).
|
||||
pub fn pump(&mut self, cancel: bool, ctx: &egui::Context) -> Option<Vec<OcrPage>> {
|
||||
POOL.with_borrow_mut(|pool| {
|
||||
let pool = pool.get_or_insert_with(|| Pool::start(ctx));
|
||||
pool.update();
|
||||
for (id, lines) in self.inbox.take() {
|
||||
if let Some(k) = self.reading.iter().position(|r| r.0 == id) {
|
||||
let (_, order, shot) = self.reading.swap_remove(k);
|
||||
self.found.push((order, place(&shot, lines)));
|
||||
}
|
||||
}
|
||||
if cancel {
|
||||
return Some(self.finish());
|
||||
}
|
||||
let worker = pool.idle();
|
||||
if !self.rendered_all && (worker.is_some() || !pool.usable()) {
|
||||
match self.steps.next_page() {
|
||||
None => self.rendered_all = true,
|
||||
Some(Err(skipped)) => {
|
||||
let order = self.take_order();
|
||||
self.found.push((order, skipped));
|
||||
}
|
||||
Some(Ok(mut shot)) => {
|
||||
let order = self.take_order();
|
||||
match worker.zip(shot.image()).and_then(|(i, image)| pool.send(i, image, &self.inbox)) {
|
||||
Some(id) => {
|
||||
// The worker has its own copy; drop this one.
|
||||
shot.take_image();
|
||||
self.reading.push((id, order, shot));
|
||||
}
|
||||
// No worker can take it: read it here, with the job's engines.
|
||||
None => {
|
||||
let page = match pdfcraft_engine::ocr::recognizers(self.steps.settings()) {
|
||||
Ok(r) => shot.read(&r),
|
||||
Err(e) => shot.place(Err(e)),
|
||||
};
|
||||
self.found.push((order, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.rendered_all && self.reading.is_empty()).then(|| self.finish())
|
||||
})
|
||||
}
|
||||
|
||||
fn take_order(&mut self) -> usize {
|
||||
self.next += 1;
|
||||
self.next - 1
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Vec<OcrPage> {
|
||||
let mut found = std::mem::take(&mut self.found);
|
||||
found.sort_by_key(|f| f.0);
|
||||
found.into_iter().map(|f| f.1).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Place what a worker read: its one engine's reading (every strategy reads that way without a
|
||||
/// second engine, and the web build has only ocrs).
|
||||
fn place(shot: &OcrShot, lines: Result<Vec<Line>, String>) -> OcrPage {
|
||||
shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() }))
|
||||
}
|
||||
|
||||
/// Recognize text in multiple files, one file at a time through [`Pages`]; each searchable copy
|
||||
/// downloads when it's done. The counts are what `ocr_ui::batch_summary` reports.
|
||||
pub struct Batch {
|
||||
files: VecDeque<(String, Vec<u8>)>,
|
||||
settings: OcrSettings,
|
||||
current: Option<(String, Box<FileJob>, Pages)>,
|
||||
total: usize,
|
||||
pub ok: usize,
|
||||
pub skipped: usize,
|
||||
pub words: usize,
|
||||
pub failed: Vec<String>,
|
||||
/// The low-confidence notes, one per file that has such words.
|
||||
pub notes: Vec<String>,
|
||||
/// After a cancel: how many files were not run.
|
||||
pub not_run: Option<usize>,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn new(files: Vec<(String, Vec<u8>)>, settings: OcrSettings) -> Batch {
|
||||
let total = files.len();
|
||||
Batch {
|
||||
files: files.into(),
|
||||
settings,
|
||||
current: None,
|
||||
total,
|
||||
ok: 0,
|
||||
skipped: 0,
|
||||
words: 0,
|
||||
failed: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
not_run: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files finished so far.
|
||||
pub fn done(&self) -> usize {
|
||||
self.total.saturating_sub(self.files.len() + usize::from(self.current.is_some()))
|
||||
}
|
||||
|
||||
/// Advance; `true` once every file is done. A cancel is honoured between files: the file
|
||||
/// in flight finishes, the rest are counted as not run.
|
||||
pub fn pump(&mut self, cancel: bool, ctx: &egui::Context) -> bool {
|
||||
let Some((name, _, pages)) = self.current.as_mut() else {
|
||||
if cancel && !self.files.is_empty() {
|
||||
self.not_run = Some(self.files.len());
|
||||
self.files.clear();
|
||||
}
|
||||
let Some((name, bytes)) = self.files.pop_front() else { return true };
|
||||
match FileJob::open(&name, Arc::new(bytes), None, self.settings.clone()) {
|
||||
Ok(Opened::Skipped(_)) => self.skipped += 1,
|
||||
Ok(Opened::Read(mut file)) => match file.job.take() {
|
||||
Some(job) => self.current = Some((name, file, Pages::new(job.steps()))),
|
||||
None => self.failed.push(format!("{name}: the document could not be read")),
|
||||
},
|
||||
Err(e) => self.failed.push(format!("{name}: {e}")),
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let Some(found) = pages.pump(false, ctx) else { return false };
|
||||
let name = std::mem::take(name);
|
||||
let Some((_, file, _)) = self.current.take() else { return false };
|
||||
let saved = file.finish(found).and_then(|r| {
|
||||
crate::editing::download(&name, &r.bytes)?;
|
||||
Ok(r)
|
||||
});
|
||||
match saved {
|
||||
Ok(r) => {
|
||||
self.ok += 1;
|
||||
self.words += r.words();
|
||||
if let Some(note) = crate::ocr_ui::low_confidence_note(&name, &r, self.settings.low_confidence_threshold) {
|
||||
self.notes.push(note);
|
||||
}
|
||||
}
|
||||
Err(e) => self.failed.push(format!("{name}: {e}")),
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
#
|
||||
# Usage: packaging/web/package.sh [--skip-build]
|
||||
#
|
||||
# Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown
|
||||
# target. The zip holds a self-contained static site in pdfcraft-web-<version>/ that works
|
||||
# from any URL path and inside an <iframe>. Hosting notes: packaging/web/README.md.
|
||||
# Needs: trunk (brew install trunk / cargo install trunk --locked), the wasm32-unknown-unknown
|
||||
# target and network access for the OCR models (cargo xtask models). The zip holds a
|
||||
# self-contained static site in pdfcraft-web-<version>/ that works from any URL path and inside
|
||||
# an <iframe>. Hosting notes: packaging/web/README.md.
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
@@ -29,6 +30,8 @@ WORK="$CARGO_TARGET_DIR/web-package"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/$NAME"
|
||||
cp -R "$SITE/." "$WORK/$NAME/"
|
||||
# Scan & OCR downloads its models from models/ beside index.html the first time it's used.
|
||||
stage_models "$WORK/$NAME/models"
|
||||
# Sample server configs (MIME type, caching, compression); harmless where unused.
|
||||
cp "$HERE/_headers" "$HERE/.htaccess" "$WORK/$NAME/"
|
||||
cp "$HERE/README.md" "$WORK/$NAME/HOSTING.md"
|
||||
|
||||
+2
-1
@@ -90,6 +90,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("pdfcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("ocr-worker", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
@@ -356,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn apps_exempt() {
|
||||
for app in ["pdfcraft", "pdfcraft-cli", "pdfcraft-web", "xtask"] {
|
||||
for app in ["pdfcraft", "pdfcraft-cli", "pdfcraft-web", "pdfcraft-ocr-worker", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("pdfcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod netcheck;
|
||||
mod parity;
|
||||
mod screenshots;
|
||||
mod version;
|
||||
mod web;
|
||||
|
||||
type Command = fn(&[String]) -> anyhow::Result<()>;
|
||||
|
||||
@@ -55,6 +56,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[
|
||||
("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run),
|
||||
("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models),
|
||||
("demo-pdf", "Build dist/demo/pdfcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),
|
||||
(
|
||||
"web-worker-link",
|
||||
"Hash the web build's OCR worker files and name the worker in index.html (a Trunk post-build hook runs it)",
|
||||
web::worker_link,
|
||||
),
|
||||
];
|
||||
|
||||
fn version_cmd(args: &[String]) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! `cargo xtask web-worker-link [DIR]`: give the web build's OCR worker hashed file names and
|
||||
//! tell the page where it is.
|
||||
//!
|
||||
//! Trunk names the page's files after a hash of their contents, so hosts can cache them forever,
|
||||
//! but leaves a worker's files under their plain names (`pdfcraft-ocr-worker.js`, `_bg.wasm`,
|
||||
//! `_loader.js`) and writes nothing into `index.html` for it. A browser holding an old worker
|
||||
//! script with a new `.wasm` would break, so Trunk runs this after each build
|
||||
//! (apps/pdfcraft-web/Trunk.toml, `TRUNK_STAGING_DIR`): it renames the three files to
|
||||
//! `pdfcraft-ocr-worker-<hash>…`, updates the names inside them, and adds
|
||||
//! `<meta name="pdfcraft-ocr-worker" content="./pdfcraft-ocr-worker-<hash>_loader.js">`, which
|
||||
//! crates/ui-egui/src/workflows/ocr_web.rs reads.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// The `<meta>` name the page looks for.
|
||||
const META: &str = "pdfcraft-ocr-worker";
|
||||
const STEM: &str = "pdfcraft-ocr-worker";
|
||||
|
||||
pub fn worker_link(args: &[String]) -> Result<()> {
|
||||
let dir = match args.first() {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => PathBuf::from(std::env::var_os("TRUNK_STAGING_DIR").context("give the site's folder, or run from a Trunk hook (TRUNK_STAGING_DIR)")?),
|
||||
};
|
||||
let read = |name: &str| std::fs::read(dir.join(name)).with_context(|| format!("reading {}", dir.join(name).display()));
|
||||
let worker = Worker { js: read(&format!("{STEM}.js"))?, wasm: read(&format!("{STEM}_bg.wasm"))?, loader: read(&format!("{STEM}_loader.js"))? };
|
||||
let hashed = worker.hashed()?;
|
||||
for (name, bytes) in &hashed.files {
|
||||
write(&dir.join(name), bytes)?;
|
||||
}
|
||||
for old in [format!("{STEM}.js"), format!("{STEM}_bg.wasm"), format!("{STEM}_loader.js")] {
|
||||
std::fs::remove_file(dir.join(&old)).with_context(|| format!("removing {old}"))?;
|
||||
}
|
||||
let html_path = dir.join("index.html");
|
||||
let html = std::fs::read_to_string(&html_path).with_context(|| format!("reading {}", html_path.display()))?;
|
||||
write(&html_path, add_worker_meta(&html, &hashed.loader)?.as_bytes())?;
|
||||
println!("web-worker-link: the page loads its OCR worker from {}", hashed.loader);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
/// The worker's three files as Trunk wrote them.
|
||||
struct Worker {
|
||||
js: Vec<u8>,
|
||||
wasm: Vec<u8>,
|
||||
loader: Vec<u8>,
|
||||
}
|
||||
|
||||
struct Hashed {
|
||||
/// The files to write, by name.
|
||||
files: Vec<(String, Vec<u8>)>,
|
||||
/// The loader's new name.
|
||||
loader: String,
|
||||
}
|
||||
|
||||
/// Replace the one occurrence of `from` in `text` with `to`.
|
||||
fn replace_once(text: &str, from: &str, to: &str, what: &str) -> Result<String> {
|
||||
match text.matches(from).count() {
|
||||
1 => Ok(text.replacen(from, to, 1)),
|
||||
n => bail!("expected {what} to name {from} once, found {n}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn hashed(&self) -> Result<Hashed> {
|
||||
let mut both = self.js.clone();
|
||||
both.extend_from_slice(&self.wasm);
|
||||
let hash: String = crate::assets::sha256_hex(&both).chars().take(16).collect();
|
||||
let (js, wasm, loader) = (format!("{STEM}-{hash}.js"), format!("{STEM}-{hash}_bg.wasm"), format!("{STEM}-{hash}_loader.js"));
|
||||
let glue = std::str::from_utf8(&self.js).context("the worker's script is not UTF-8")?;
|
||||
let glue = replace_once(glue, &format!("{STEM}_bg.wasm"), &wasm, "the worker's script")?;
|
||||
let shim = std::str::from_utf8(&self.loader).context("the worker's loader is not UTF-8")?;
|
||||
let shim = replace_once(shim, &format!("./{STEM}.js"), &format!("./{js}"), "the worker's loader")?;
|
||||
Ok(Hashed { files: vec![(js, glue.into_bytes()), (wasm, self.wasm.clone()), (loader.clone(), shim.into_bytes())], loader })
|
||||
}
|
||||
}
|
||||
|
||||
/// `html` with the meta tag naming `loader` (replacing an earlier one).
|
||||
fn add_worker_meta(html: &str, loader: &str) -> Result<String> {
|
||||
if loader.contains(['"', '<', '>', '&']) {
|
||||
bail!("unexpected characters in the worker loader's name: {loader}");
|
||||
}
|
||||
let tag = format!("<meta name=\"{META}\" content=\"./{loader}\" />");
|
||||
let without: String = html.lines().filter(|l| !l.contains(&format!("name=\"{META}\""))).map(|l| format!("{l}\n")).collect();
|
||||
let Some(head_end) = without.find("</head>") else {
|
||||
bail!("index.html has no </head>");
|
||||
};
|
||||
Ok(format!("{} {tag}\n {}", without[..head_end].trim_end_matches([' ', '\t']), &without[head_end..]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn trunk_output(wasm: &[u8]) -> Worker {
|
||||
Worker {
|
||||
js: b"function f(){ module_or_path = new URL('pdfcraft-ocr-worker_bg.wasm', import.meta.url); }".to_vec(),
|
||||
wasm: wasm.to_vec(),
|
||||
loader: b"import init from './pdfcraft-ocr-worker.js';await init();".to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_files_get_content_hashed_names_that_point_at_each_other() {
|
||||
let h = trunk_output(b"\0asm one").hashed().unwrap();
|
||||
let names: Vec<&str> = h.files.iter().map(|f| f.0.as_str()).collect();
|
||||
let hash = &h.loader["pdfcraft-ocr-worker-".len()..h.loader.len() - "_loader.js".len()];
|
||||
assert_eq!(hash.len(), 16);
|
||||
assert_eq!(names, [format!("{STEM}-{hash}.js"), format!("{STEM}-{hash}_bg.wasm"), h.loader.clone()]);
|
||||
let text = |i: usize| String::from_utf8(h.files[i].1.clone()).unwrap();
|
||||
assert!(text(0).contains(&format!("new URL('{STEM}-{hash}_bg.wasm', import.meta.url)")), "{}", text(0));
|
||||
assert_eq!(text(2), format!("import init from './{STEM}-{hash}.js';await init();"));
|
||||
assert_eq!(h.files[1].1, b"\0asm one");
|
||||
// A different build gets a different name, so a cached old worker is never paired with it.
|
||||
assert_ne!(trunk_output(b"\0asm two").hashed().unwrap().loader, h.loader);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_worker_files_are_refused() {
|
||||
let mut w = trunk_output(b"\0asm");
|
||||
w.js = b"no wasm named here".to_vec();
|
||||
assert!(w.hashed().is_err());
|
||||
let mut w = trunk_output(b"\0asm");
|
||||
w.loader = b"import a from './pdfcraft-ocr-worker.js'; import b from './pdfcraft-ocr-worker.js';".to_vec();
|
||||
assert!(w.hashed().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_names_the_loader_once() {
|
||||
let html = "<html>\n <head>\n <title>PdfCraft</title>\n </head>\n <body></body>\n</html>\n";
|
||||
let once = add_worker_meta(html, "pdfcraft-ocr-worker-9f8e_loader.js").unwrap();
|
||||
assert!(once.contains("<meta name=\"pdfcraft-ocr-worker\" content=\"./pdfcraft-ocr-worker-9f8e_loader.js\" />\n </head>"), "{once}");
|
||||
// Running again replaces the tag rather than adding another.
|
||||
let twice = add_worker_meta(&once, "pdfcraft-ocr-worker-7777_loader.js").unwrap();
|
||||
assert_eq!(twice.matches("pdfcraft-ocr-worker").count(), 2, "{twice}");
|
||||
assert!(twice.contains("./pdfcraft-ocr-worker-7777_loader.js"));
|
||||
assert!(twice.contains("<title>PdfCraft</title>") && twice.ends_with("</html>\n"));
|
||||
assert!(add_worker_meta(html, "pdfcraft-ocr-worker-\"x_loader.js").is_err());
|
||||
assert!(add_worker_meta("<body></body>", "pdfcraft-ocr-worker_loader.js").is_err());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user