Merge remote-tracking branch 'origin/main' into merge/3492-thai-line-breaks

This commit is contained in:
bflatastic committed 2026-11-09 00:15:27 +07:00
commit f6831d58ea
44 files changed
+2411 -231

No files matched your search

+5
View File
@@ -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"]
+2 -2
View File
@@ -1,4 +1,4 @@
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
<!-- craft-house-rules:start core source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
## House rules
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
@@ -19,7 +19,7 @@ These apply in every Craft repository. A later rule may be stricter, not weaker.
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
<!-- craft-house-rules:end -->
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
<!-- craft-house-rules:start rust source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=642f46b4ac38cc9e4d10d1ccde19d68cb6bc03d20e4c6236a7535f3047566262 -->
## Rust Craft repositories
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
Generated
+12
View File
@@ -4562,10 +4562,22 @@ dependencies = [
"pdfcraft-fonts",
"png 0.18.1",
"rten",
"serde_json",
"sha2 0.11.1",
"tempfile",
"thiserror 2.0.21",
]
[[package]]
name = "pdfcraft-ocr-worker"
version = "0.18.1"
dependencies = [
"js-sys",
"pdfcraft-ocr",
"wasm-bindgen",
"web-sys",
]
[[package]]
name = "pdfcraft-optimize"
version = "0.18.1"
+7
View File
@@ -238,6 +238,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
- **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). 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.
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
@@ -275,6 +277,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
@@ -286,6 +289,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
- **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
- **2026-10-24 (M10, `ocr.ocr-web`, review fix):** On the web, single-file and multiple-file OCR running at once no longer lose a page: the workers' answers go to the run that sent each page, instead of to whichever run collected first, and a cancelled run's late answers are let go. Regression tests cover both start orders and cancellation. Files chosen for Recognize Text in Multiple Files before the models have downloaded now wait for them instead of having to be chosen again, and idle workers close on time even when nothing is happening on the page, a full minute after their last page comes back rather than after it was handed out (checked by hand in Firefox); estimates unchanged.
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
@@ -565,6 +570,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
Newest first: the date, what moved, and the new overall estimate.
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
+18
View File
@@ -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"] }
+101
View File
@@ -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 u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(4)) != Some(px.len() as u64) {
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())
}
}
+7
View File
@@ -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"]
+2
View File
@@ -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; }
+7
View File
@@ -37,6 +37,13 @@ pub struct TextBlock {
}
impl TextBlock {
/// The one size a rewrite and the editor set an OCR phrase in, in place of each word's own
/// (#3313): the block's size, which for invisible text is its words' median. `None` for
/// visible text, whose runs keep their sizes.
pub fn phrase_size(&self) -> Option<f64> {
(self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size)
}
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
/// source run. The texts are aligned word by word, so words left untouched between two edits
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
+10 -4
View File
@@ -749,12 +749,13 @@ struct ParaStyle {
k: f64,
}
/// Spacing and scaling (text state), with the chosen values.
fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> {
/// Spacing and scaling (text state), with the chosen values. `phrase` is the size an OCR phrase
/// is drawn at when none is chosen, in place of its first word's own (#3313).
fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option<f64>) -> Result<(String, ParaStyle), EditError> {
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
let k = o.k.max(1e-6);
// The size in text space: points ÷ the text-to-user scale.
let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1));
let size = style.size.or(phrase).map_or(old_size, |pt| (pt / k).max(0.1));
let mut state = o.state.clone();
if let Some(c) = style.char_spacing {
state.char_spacing = c / k;
@@ -762,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle)
if let Some(sc) = style.scale {
state.scale = (sc / 100.0).clamp(0.1, 10.0);
}
// At the phrase's size, the first word's text matrix still carries its own width: scale it
// back, so the line is as wide as the scan whatever size OCR guessed for that word.
if phrase.is_some() && style.size.is_none() && style.scale.is_none() {
state.scale = (state.scale * old_size / size).clamp(0.1, 10.0);
}
Ok((font_name, ParaStyle { state, size, old_size, k }))
}
@@ -842,7 +848,7 @@ pub(crate) fn rewrite_block_from(
let o = &first.origin;
let last = members.last().copied().unwrap_or(first);
let emit = &last.origin;
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?;
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?;
let source = source_font(doc, &fonts_res, &font_name, first, &flat, style);
let reuse = source.reuse;
// The chosen style also determines the real Japanese fallback outlines and advances.
+22
View File
@@ -398,6 +398,17 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
for block in &mut blocks {
block.reflow_text = reflow_text(block, lines);
}
for block in blocks.iter_mut().filter(|block| block.invisible && block.lines.len() > 1) {
// Each word counts once: neighbouring words OCR sized alike share a run.
let words = block
.lines
.iter()
.filter_map(|i| lines.get(*i))
.flat_map(|line| std::iter::repeat_n(line.size, line.text.split_whitespace().count().max(1)));
if let Some(size) = median_size(words) {
block.size = size;
}
}
blocks
}
@@ -452,3 +463,14 @@ fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option<
// measure leaves almost no room for the next word.
(slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1)
}
/// The size an OCR phrase is set in. Recognize text sizes each word from its own box and a guess
/// at how much of the em its letters fill, so one phrase's words come out several points apart
/// (#3313); a rewrite draws the phrase at this one size. `None` when no size is usable.
fn median_size(sizes: impl Iterator<Item = f64>) -> Option<f64> {
let mut sizes: Vec<f64> = sizes.filter(|size| size.is_finite() && *size > 0.0).collect();
sizes.sort_by(f64::total_cmp);
let middle = sizes.len() / 2;
let upper = sizes.get(middle).copied()?;
if sizes.len().is_multiple_of(2) { sizes.get(middle.saturating_sub(1)).map(|lower| (lower + upper) / 2.0) } else { Some(upper) }
}
+21 -2
View File
@@ -321,6 +321,23 @@ fn calibration_factors(
calibration
}
/// The calibration factors fit each word to its box at its own size (its `Tf` in its text space,
/// which a range's size doesn't change). An OCR phrase drawn at one size needs the inverse ratio
/// too, so every word still spans the box the scan shows it in. The ratio is not clamped again:
/// a word OCR sized at three times the phrase's needs three times the calibration.
fn fit_to_phrase(calibration: &mut HashMap<usize, f64>, lines: &[TextLine], phrase: Option<f64>) {
let Some(phrase) = phrase else { return };
for (index, factor) in calibration.iter_mut() {
let own = lines.get(*index).and_then(|source| Some(source.origin.state.font.as_ref()?.1 * source.origin.k));
if let Some(own) = own.filter(|own| own.is_finite() && *own > 0.0) {
let fitted = *factor * own / phrase;
if fitted.is_finite() {
*factor = fitted;
}
}
}
}
fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result<Vec<&'a RangeStyle>, EditError> {
let mut ranges: Vec<_> = style.ranges.iter().collect();
ranges.sort_by_key(|range| range.range.start);
@@ -911,8 +928,9 @@ pub(crate) fn rewrite_styled_block(
.prepare()?;
let chars = prepared.values;
let substituted = prepared.substituted;
let phrase_size = block.phrase_size();
let size_of = |source: &TextLine, index: usize| {
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
overrides.get(&index).and_then(|r| r.size).or(style.size).or(phrase_size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
};
// A source run may carry TJ kerning/justification that is absent from its text state.
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
@@ -930,7 +948,8 @@ pub(crate) fn rewrite_styled_block(
spaces.borrow_mut().insert(name.to_owned(), w);
w
};
let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
let mut calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
fit_to_phrase(&mut calibration, lines, phrase_size);
let advance = |item: &(char, usize, String)| -> f64 {
let source = lines.get(item.1).unwrap_or(first);
let Some(m) = metrics.get(&item.2) else { return 0.0 };
+5 -3
View File
@@ -333,11 +333,13 @@ impl crate::Document {
let (pages, truncated_pages) =
self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES);
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
// Word and HTML name the document's language (`/Lang`), so spelling is checked in it.
let language = self.initial_view().language;
let bytes = match format {
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
OfficeFormat::Docx => pdfcraft_export::docx_with_language(&pages, &title, language.as_deref()),
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(),
OfficeFormat::Html => pdfcraft_export::html_with_language(&pages, &title, language.as_deref()).into_bytes(),
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple_with_language(&pages, &title, language.as_deref()).into_bytes(),
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
};
+382 -159
View File
@@ -15,7 +15,8 @@ use pdfcraft_render::text::TextFilter;
use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind};
pub use pdfcraft_ocr::{
ConfidenceBand, LANGUAGES, MergeStrategy, Models, Ocr, OcrError, PlacedWord, Recognizer, band_for, is_suspect, low_confidence,
ConfidenceBand, DETECTION_MODEL, LANGUAGES, Line, MergeStrategy, Models, Ocr, OcrError, OcrImage, PlacedWord, RECOGNITION_MODEL, Recognizer,
StrategyOutcome, Word, band_for, is_suspect, lines_from_json, low_confidence,
};
use crate::{DocId, Edit, EditError, Session};
@@ -257,6 +258,23 @@ struct EngineCache {
ocrs: Option<Arc<dyn Recognizer>>,
}
/// The engines built so far (see [`recognizers`] and [`install`]).
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
/// Share an ocrs recogniser loaded some other way than from the model files [`Models::find`]
/// looks for: the web build downloads the models and loads them with [`Ocr::from_bytes`]. From
/// then on [`recognizers`] hands it out and the engine counts as available.
pub fn install(ocr: Ocr) -> Arc<dyn Recognizer> {
let ocrs: Arc<dyn Recognizer> = Arc::new(pdfcraft_ocr::OcrsRecognizer::from_ocr(ocr));
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| EngineCache { ocrs: None }).ocrs = Some(ocrs.clone());
ocrs
}
/// Whether an ocrs recogniser has been loaded (from the model files, or by [`install`]).
fn ocrs_loaded() -> bool {
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|c| c.ocrs.is_some())
}
/// How long a negative engine probe (no program there) is remembered. A short TTL, not
/// forever: a tesseract installed while the app runs is found by the next job, without a
/// restart. An engine that was found is remembered for the session.
@@ -408,10 +426,11 @@ fn models_installed_cached() -> bool {
found
}
/// The web build has no disk search worth caching.
/// The web build has no model files: the models count as installed once [`install`] has
/// loaded the ones it downloaded.
#[cfg(target_arch = "wasm32")]
fn models_installed_cached() -> bool {
Models::find().is_some()
ocrs_loaded()
}
/// The program's `--version` line, remembered per program path for the session (`ocr_status`
@@ -473,7 +492,7 @@ pub fn engines_status() -> Vec<EngineStatus> {
/// takes a location, because an agent must not choose which program runs; the tools report the
/// default settings.)
pub fn engines_status_for(settings: &OcrSettings) -> Vec<EngineStatus> {
let ocrs = Models::find().is_some();
let ocrs = ocrs_loaded() || Models::find().is_some();
let mut out = vec![EngineStatus { id: "ocrs", available: ocrs, why_not: (!ocrs).then(models_missing), program: None, version: None }];
#[cfg(not(target_arch = "wasm32"))]
{
@@ -524,7 +543,6 @@ pub fn recognizers(settings: &OcrSettings) -> Result<Recognizers, String> {
let configured = configured_tesseract(settings);
let tesseract =
if matches!(settings.engine, EngineChoice::Tesseract | EngineChoice::Auto) { tesseract_engine(configured.as_deref()) } else { None };
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
let mut slot = ENGINES.lock().unwrap_or_else(|e| e.into_inner());
let cache = slot.get_or_insert_with(|| EngineCache { ocrs: None });
if cache.ocrs.is_none() {
@@ -567,7 +585,7 @@ fn models_missing() -> String {
/// never waits.
pub fn available() -> bool {
let configured = std::env::var_os("PDFCRAFT_TESSERACT").map(|v| v.to_string_lossy().into_owned());
Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
ocrs_loaded() || Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
}
/// Why `engine` cannot run right now, named for the UI's tooltips (the Engine dropdown
@@ -703,138 +721,226 @@ impl OcrJob {
/// Read the pages. `progress(done, total)` is called before each page; returning `false`
/// stops (the pages read so far are returned).
pub fn run(self, recognizers: &Recognizers, mut progress: impl FnMut(usize, usize) -> bool) -> Vec<OcrPage> {
let config =
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
let mut r = PageRenderer::new(self.bytes.clone(), config);
let mut out = Vec::new();
let total = self.pages.len();
let options = pdfcraft_ocr::RecognizeOptions { language: self.settings.language.clone() };
for (done, &page) in self.pages.iter().enumerate() {
if !progress(done, total) {
break;
let mut steps = self.steps();
let mut out = Vec::new();
while progress(steps.done(), total) {
match steps.next_page() {
None => break,
Some(Err(skipped)) => out.push(skipped),
Some(Ok(shot)) => out.push(shot.read(recognizers)),
}
let Some(info) = self.infos.get(page) else { continue };
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
// only /ActualText names, so neither is read again.
let existing: Vec<[f32; 4]> = r
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
.unwrap_or_default();
if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() && has_own_text(&mut r, page) {
out.push(skip("the page already contains text"));
continue;
}
// A scan is read at its own resolution (enlarging it only blurs the letters), at
// most the chosen one.
let scale = self.render_scale(page);
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
if let Some(e) = shot.error {
out.push(skip(&e));
continue;
}
let full = match pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()) {
Ok(img) => img,
Err(e) => {
out.push(skip(&e.to_string()));
continue;
}
};
// The optional region: crop the rendered page, clamped; an empty region skips the
// page with a named reason (nothing was asked of it).
let (image, offset) = match self.settings.region {
None => (full, [0.0f32; 2]),
Some(region) => match clamp_region(region, shot.width, shot.height) {
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
Some(c) => (c, [x0 as f32, y0 as f32]),
None => {
out.push(skip("the selected region is empty"));
continue;
}
},
None => {
out.push(skip("the selected region is outside the page"));
continue;
}
},
};
// Preprocessing sits between the page raster and recognition; its word boxes come
// back in processed coordinates and are mapped to the ORIGINAL raster's before
// placement. The render dpi is the real one (pixels per point × 72), never a
// fixed value.
let pre = pdfcraft_ocr::preprocess::preprocess(
image,
scale * 72.0,
&pdfcraft_ocr::preprocess::PreprocessOptions {
auto_rotate: self.settings.auto_rotate,
deskew: self.settings.deskew,
denoise: self.settings.denoise,
binarize: self.settings.binarize,
},
);
let (image, inverse) = match pre {
Ok(p) => (p.image, p.inverse_transform),
Err(e) => {
out.push(skip(&e.to_string()));
continue;
}
};
let outcome = match pdfcraft_ocr::recognize_with_strategy(
recognizers.primary.as_ref(),
recognizers.secondary.as_deref(),
self.settings.strategy,
&image,
&options,
) {
Ok(o) => o,
Err(e) => {
out.push(skip(&e.to_string()));
continue;
}
};
let scale = shot.width as f32 / info.width.max(1e-3);
let to_user = |x: f32, y: f32| {
// processed pixels → original raster pixels (the exact inverse) → past the
// region crop → page view space.
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
[u as f64, v as f64]
};
// Validate recognition geometry and compare against existing text in view space,
// undoing preprocessing and region offsets before testing overlap.
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
let [l, t, r, b] = w.rect;
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|mut bounds, (x, y)| {
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
let x = (x as f32 + offset[0]) / scale;
let y = (y as f32 + offset[1]) / scale;
bounds[0] = bounds[0].min(x);
bounds[1] = bounds[1].min(y);
bounds[2] = bounds[2].max(x);
bounds[3] = bounds[3].max(y);
bounds
},
);
covered(bounds, &existing) < ALREADY_ON_PAGE
});
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
let mut notes = outcome.notes;
// Only visible editable output needs display glyphs. Searchable output preserves
// every Unicode character with empty Type3 programs and ToUnicode maps.
if self.settings.output_mode == OutputMode::EditableText && !words.is_empty() {
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
if missing > 0 {
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
}
}
out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None, notes });
}
progress(total, total);
out
}
/// Read the pages one at a time, preparing each page's image and recognising it separately:
/// the web build prepares pages on the page and recognises them in Web Workers.
pub fn steps(self) -> OcrSteps {
let config =
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
let renderer = PageRenderer::new(self.bytes.clone(), config);
OcrSteps { job: self, renderer, next: 0 }
}
}
/// [`OcrJob`] a page at a time: [`OcrSteps::next_page`] renders the next page, crops it to the
/// region and preprocesses it; an engine reads [`OcrShot::image`], and [`OcrShot::place`] puts
/// the words found on the page ([`OcrShot::read`] does both with the job's engines).
pub struct OcrSteps {
job: OcrJob,
renderer: PageRenderer,
next: usize,
}
impl OcrSteps {
/// The number of pages to read.
pub fn total(&self) -> usize {
self.job.pages.len()
}
/// The number of pages prepared (or skipped) so far.
pub fn done(&self) -> usize {
self.next
}
/// The settings the job reads with.
pub fn settings(&self) -> &OcrSettings {
&self.job.settings
}
/// Prepare the next page for recognition. `Err` is a page that isn't read (it already has
/// text, or couldn't be rendered or prepared), with the reason; `None` once every page is
/// done.
pub fn next_page(&mut self) -> Option<Result<OcrShot, OcrPage>> {
loop {
let page = *self.job.pages.get(self.next)?;
self.next += 1;
let Some(info) = self.job.infos.get(page).cloned() else { continue };
return Some(self.prepare(page, info));
}
}
fn prepare(&mut self, page: usize, info: PageInfo) -> Result<OcrShot, OcrPage> {
let settings = &self.job.settings;
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
let r = &mut self.renderer;
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
// only /ActualText names, so neither is read again.
let existing: Vec<[f32; 4]> = r
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
.text
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
.unwrap_or_default();
if settings.skip_text_pages && !settings.force_ocr && !existing.is_empty() && has_own_text(r, page) {
return Err(skip("the page already contains text"));
}
// A scan is read at its own resolution (enlarging it only blurs the letters), at
// most the chosen one.
let scale = self.job.render_scale(page);
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
if let Some(e) = shot.error {
return Err(skip(&e));
}
let full = pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()).map_err(|e| skip(&e.to_string()))?;
// The optional region: crop the rendered page, clamped; an empty region skips the
// page with a named reason (nothing was asked of it).
let (image, offset) = match settings.region {
None => (full, [0.0f32; 2]),
Some(region) => match clamp_region(region, shot.width, shot.height) {
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
Some(c) => (c, [x0 as f32, y0 as f32]),
None => return Err(skip("the selected region is empty")),
},
None => return Err(skip("the selected region is outside the page")),
},
};
// Preprocessing sits between the page raster and recognition; its word boxes come
// back in processed coordinates and are mapped to the ORIGINAL raster's before
// placement. The render dpi is the real one (pixels per point × 72), never a
// fixed value.
let pre = pdfcraft_ocr::preprocess::preprocess(
image,
scale * 72.0,
&pdfcraft_ocr::preprocess::PreprocessOptions {
auto_rotate: settings.auto_rotate,
deskew: settings.deskew,
denoise: settings.denoise,
binarize: settings.binarize,
},
)
.map_err(|e| skip(&e.to_string()))?;
Ok(OcrShot {
page,
image: Some(pre.image),
inverse: pre.inverse_transform,
offset,
pixels_per_point: shot.width as f32 / info.width.max(1e-3),
info,
existing,
strategy: settings.strategy,
options: pdfcraft_ocr::RecognizeOptions { language: settings.language.clone() },
output_mode: settings.output_mode,
})
}
}
/// A page prepared for recognition: the image an engine reads, and what placing the words
/// found in it needs.
pub struct OcrShot {
pub page: usize,
/// The page's pixels as the engines read them: rendered, cropped to the region and
/// preprocessed (`None` once [`OcrShot::take_image`] has handed them on).
image: Option<OcrImage>,
/// Processed pixels → the rendered raster's (the exact inverse of preprocessing).
inverse: pdfcraft_ocr::preprocess::InverseTransform,
/// Where the region crop starts in the rendered raster.
offset: [f32; 2],
/// The rendered raster's pixels per point of page view space.
pixels_per_point: f32,
info: PageInfo,
/// The boxes of the page's own text, in view space.
existing: Vec<[f32; 4]>,
strategy: MergeStrategy,
options: pdfcraft_ocr::RecognizeOptions,
output_mode: OutputMode,
}
impl OcrShot {
/// What the engines read (`None` once taken).
pub fn image(&self) -> Option<&OcrImage> {
self.image.as_ref()
}
/// Hand the image on (the web build sends it to a worker), keeping what placing the words
/// needs: the page's pixels need not stay in memory while the worker reads them.
pub fn take_image(&mut self) -> Option<OcrImage> {
self.image.take()
}
/// Recognise the image with `recognizers` (per the job's strategy) and place the words.
pub fn read(&self, recognizers: &Recognizers) -> OcrPage {
let Some(image) = &self.image else { return self.place(Err("the page's image was handed to another reader".into())) };
let outcome = pdfcraft_ocr::recognize_with_strategy(
recognizers.primary.as_ref(),
recognizers.secondary.as_deref(),
self.strategy,
image,
&self.options,
);
self.place(outcome.map_err(|e| e.to_string()))
}
/// Put the lines recognised in [`Self::image`] on the page (or skip the page with the
/// error). A single engine's reading, which is all the web build's workers run, is
/// `StrategyOutcome { lines, ..Default::default() }`: every strategy reads that way when
/// there is no second engine.
pub fn place(&self, outcome: Result<StrategyOutcome, String>) -> OcrPage {
let page = self.page;
let outcome = match outcome {
Ok(o) => o,
Err(e) => return OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(e), notes: Vec::new() },
};
let (info, inverse, offset, scale) = (&self.info, self.inverse, self.offset, self.pixels_per_point);
let to_user = |x: f32, y: f32| {
// processed pixels → original raster pixels (the exact inverse) → past the
// region crop → page view space.
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
[u as f64, v as f64]
};
// Validate recognition geometry and compare against existing text in view space,
// undoing preprocessing and region offsets before testing overlap.
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
let [l, t, r, b] = w.rect;
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|mut bounds, (x, y)| {
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
let x = (x as f32 + offset[0]) / scale;
let y = (y as f32 + offset[1]) / scale;
bounds[0] = bounds[0].min(x);
bounds[1] = bounds[1].min(y);
bounds[2] = bounds[2].max(x);
bounds[3] = bounds[3].max(y);
bounds
},
);
covered(bounds, &self.existing) < ALREADY_ON_PAGE
});
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
let mut notes = outcome.notes;
// Only visible editable output needs display glyphs. Searchable output preserves
// every Unicode character with empty Type3 programs and ToUnicode maps.
if self.output_mode == OutputMode::EditableText && !words.is_empty() {
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
if missing > 0 {
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
}
}
OcrPage { page, words, already_on_page: already.len(), skipped: None, notes }
}
}
/// Whether a word's pixel box may reach the content stream: width and height (right − left,
@@ -1095,36 +1201,70 @@ pub fn recognize_file(
recognizers: &Recognizers,
progress: impl FnMut(usize, usize) -> bool,
) -> Result<FileResult, String> {
let mut s = Session::new();
let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) {
return Err(why);
match FileJob::open(name, bytes, password, settings)? {
Opened::Skipped(result) => Ok(result),
Opened::Read(mut file) => {
let job = file.job.take().ok_or("the document could not be read")?;
let pages = job.run(recognizers, progress);
file.finish(pages)
}
}
let job = s.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
// The skip-files probe runs before any engine work: a file with text needs no engine at
// all, and the original bytes come back unchanged.
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
let pages = job
.pages
.iter()
.map(|&page| OcrPage {
already_on_page: 0,
page,
words: Vec::new(),
skipped: Some("the file already contains text".into()),
notes: Vec::new(),
})
.collect();
return Ok(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) });
}
/// [`recognize_file`] in two halves, for callers that read the pages themselves (the web build
/// reads them in Web Workers): [`FileJob::open`], read `job`, then [`FileJob::finish`].
pub struct FileJob {
session: Session,
id: DocId,
bytes: Arc<Vec<u8>>,
/// The pages to read (taken by the caller).
pub job: Option<OcrJob>,
}
/// What [`FileJob::open`] found: a file to read, or one the skip-files option passes over
/// (its result is final, and no engine is needed).
pub enum Opened {
Read(Box<FileJob>),
Skipped(FileResult),
}
impl FileJob {
pub fn open(name: &str, bytes: Arc<Vec<u8>>, password: Option<&str>, settings: OcrSettings) -> Result<Opened, String> {
let mut session = Session::new();
let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) {
return Err(why);
}
let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
// The skip-files probe runs before any engine work: a file with text needs no engine at
// all, and the original bytes come back unchanged.
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
let pages = job
.pages
.iter()
.map(|&page| OcrPage {
already_on_page: 0,
page,
words: Vec::new(),
skipped: Some("the file already contains text".into()),
notes: Vec::new(),
})
.collect();
return Ok(Opened::Skipped(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) }));
}
Ok(Opened::Read(Box::new(FileJob { session, id, bytes, job: Some(job) })))
}
let pages = job.run(recognizers, progress);
if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 {
return Ok(FileResult { bytes, pages, file_skipped: None });
/// Add the words found and save: the searchable file, or the original when nothing was found.
pub fn finish(mut self, pages: Vec<OcrPage>) -> Result<FileResult, String> {
if self.session.apply_ocr(self.id, &pages).map_err(|e| e.to_string())? == 0 {
return Ok(FileResult { bytes: self.bytes, pages, file_skipped: None });
}
// Checked like a save before the caller writes it (#2166).
let bytes = self.session.save_bytes(self.id).map_err(|e| e.to_string())?;
self.session.check_save(self.id, &bytes).map_err(|e| e.to_string())?;
Ok(FileResult { bytes, pages, file_skipped: None })
}
// Checked like a save before the caller writes it (#2166).
let bytes = s.save_bytes(id).map_err(|e| e.to_string())?;
s.check_save(id, &bytes).map_err(|e| e.to_string())?;
Ok(FileResult { bytes, pages, file_skipped: None })
}
#[cfg(test)]
@@ -1639,6 +1779,89 @@ mod tests {
assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text"));
}
/// The web build prepares pages with [`OcrJob::steps`] and has a worker read each image,
/// its lines coming back as JSON: the pages it ends with are the ones [`OcrJob::run`] gives,
/// with a region crop and preprocessing between the render and the engine.
#[test]
fn reading_page_by_page_matches_a_run() {
let mut s = Session::new();
let bytes = s.create_from_text("t", "One page\nanother page").unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
let settings = OcrSettings { force_ocr: true, deskew: true, binarize: true, region: Some([40.0, 30.0, 900.0, 700.0]), ..Default::default() };
let (r, fake) = fake_recognizers();
let run = s.ocr_job(id, &[], settings.clone()).unwrap().run(&r, |_, _| true);
assert!(run.iter().all(|p| p.skipped.is_none() && p.words.len() == 1), "{run:?}");
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
assert_eq!((steps.done(), steps.total()), (0, run.len()));
let mut stepped = Vec::new();
while let Some(next) = steps.next_page() {
let shot = next.unwrap();
let json = pdfcraft_ocr::lines_to_json(&fake.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap());
let lines = lines_from_json(&json, "fake").map_err(|e| e.to_string());
stepped.push(shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() })));
}
assert_eq!(steps.done(), run.len());
assert_eq!(stepped, run);
// An engine's failure skips the page with the reason, as in a run.
let mut steps = s.ocr_job(id, &[0], OcrSettings { force_ocr: true, ..Default::default() }).unwrap().steps();
let mut shot = steps.next_page().unwrap().unwrap();
let page = shot.place(Err("worker gone".into()));
assert_eq!(page.skipped.as_deref(), Some("worker gone"));
// Once the image is handed on, it can't be read here as well.
assert!(shot.take_image().is_some() && shot.image().is_none());
assert!(shot.read(&r).skipped.is_some());
assert!(steps.next_page().is_none());
}
/// A page the job doesn't read comes out of [`OcrSteps::next_page`] as skipped, with its
/// reason, before any engine is involved.
#[test]
fn page_by_page_skips_text_pages_without_an_engine() {
let mut s = Session::new();
let bytes = s.create_from_text("t", "A page with text").unwrap();
let id = s.open("t.pdf", None, bytes, None).unwrap();
let mut steps = s.ocr_job(id, &[], OcrSettings::default()).unwrap().steps();
let Some(Err(page)) = steps.next_page() else { panic!("the text page should be skipped") };
assert_eq!(page.skipped.as_deref(), Some("the page already contains text"));
}
/// The web build has no model files: a recogniser loaded from downloaded bytes is installed
/// and from then on is the engine every job reads with.
#[test]
fn an_installed_recognizer_is_available_and_handed_out() {
let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else {
eprintln!("skipped: ONNX OCR models not installed");
return;
};
let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap();
let installed = install(ocr);
assert!(installed.available());
assert!(available());
let r = recognizers(&OcrSettings::default()).unwrap();
assert!(Arc::ptr_eq(&r.primary, &installed), "the installed recogniser is the one handed out");
assert!(engines_status().iter().any(|e| e.id == "ocrs" && e.available));
}
/// The batch's halves: a file to read opens with its job, and [`FileJob::finish`] saves
/// the searchable copy; a file the skip-files option passes over is final on opening.
#[test]
fn a_file_job_reads_in_two_halves() {
let s = Session::new();
let bytes = s.create_from_text("t", "Already typed here").unwrap();
let (r, fake) = fake_recognizers();
let skip = OcrSettings { skip_text_files: true, ..Default::default() };
let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") };
assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text"));
let force = OcrSettings { force_ocr: true, ..Default::default() };
let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") };
let pages = file.job.take().unwrap().run(&r, |_, _| true);
let out = file.finish(pages).unwrap();
assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None));
let whole = recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap();
// The saved bytes carry a modification time, so two saves a second apart differ there alone.
assert_eq!((out.pages, out.file_skipped), (whole.pages, whole.file_skipped), "the same as reading it in one call");
}
/// Two inputs of one name (from different folders, or differing only in case) would
/// write the same output: both are found, each once.
#[test]
+58
View File
@@ -5801,6 +5801,35 @@ fn backgrounds_and_watermarks_from_files() {
assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err());
}
/// The web build reads a page at a time (prepared on the page, recognised in a worker, the
/// lines sent back as JSON); with the real ocrs engine that finds the same words as reading in
/// one go (needs the models: `cargo xtask models`; skipped without them).
#[test]
fn reading_a_page_at_a_time_finds_the_same_words() {
let settings = ocr::OcrSettings::default();
let Ok(engines) = ocr::recognizers(&settings) else {
eprintln!("skipped: OCR models not installed");
return;
};
let mut s = Session::new().with_clock(|| 1_700_000_000);
let text = s.create_from_text("t", "Invoice 42: pay by Friday").unwrap();
let typed = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(typed).unwrap()).png(0, 150.0).unwrap();
let scan = s.create_from_images(&[("scan.png".into(), png.clone()), ("scan2.png".into(), png)]).unwrap();
let id = s.open("scan.pdf", None, scan, None).unwrap();
let at_once = s.ocr_job(id, &[], settings.clone()).unwrap().run(&engines, |_, _| true);
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
let mut one_by_one = Vec::new();
while let Some(next) = steps.next_page() {
let shot = next.unwrap();
let lines = engines.primary.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap();
let sent = ocr::lines_from_json(&pdfcraft_ocr::lines_to_json(&lines), engines.primary.id()).map_err(|e| e.to_string());
one_by_one.push(shot.place(sent.map(|lines| ocr::StrategyOutcome { lines, ..Default::default() })));
}
assert_eq!(one_by_one, at_once);
assert!(at_once.len() == 2 && at_once.iter().all(|p| p.skipped.is_none() && !p.words.is_empty()), "{at_once:?}");
}
/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models:
/// `cargo xtask models`; skipped without them).
#[test]
@@ -6505,6 +6534,35 @@ fn actions_fail_when_hidden_information_could_not_be_removed() {
assert!(e.starts_with("Sanitize document:"), "{e}");
}
/// A PDF whose catalog names its language (`/Lang`) exports to Word and HTML in that language, so
/// Word checks the spelling in it, not in English; one that names none exports as before.
#[test]
fn office_exports_take_the_documents_language() {
let pdf = |catalog: &str| {
pdfcraft_testkit::Pdf::new()
.obj(catalog)
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>")
.stream("", b"BT /F1 12 Tf 20 200 Td (Hello) Tj ET")
.build()
};
let mut s = Session::new();
let bg = s.open("lang.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R /Lang (bg-BG) >>")), None).unwrap();
let plain = s.open("plain.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R >>")), None).unwrap();
let d = s.get(bg).unwrap();
let docx = d.export_office(compare::OfficeFormat::Docx);
assert!(docx == pdfcraft_export::docx_with_language(&d.export_pages(), "lang", Some("bg-BG")), "Word names bg-BG");
assert!(docx != pdfcraft_export::docx(&d.export_pages(), "lang"), "Word names a language");
for format in [compare::OfficeFormat::Html, compare::OfficeFormat::HtmlSimple] {
let html = String::from_utf8(d.export_office(format)).unwrap();
assert!(html.contains("<html lang=\"bg-BG\">"), "{html}");
}
let d = s.get(plain).unwrap();
assert!(d.export_office(compare::OfficeFormat::Docx) == pdfcraft_export::docx(&d.export_pages(), "plain"), "no language, as before");
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("<html>\n"));
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::HtmlSimple)).unwrap().contains("<html>\n"));
}
#[test]
fn exporting_office_files_keeps_images() {
let mut s = Session::new();
+110
View File
@@ -160,6 +160,116 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
}
}
/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill,
/// so one phrase's words come out several points apart (#3313). Here the first word is the
/// largest and the middle one the smallest; the phrase is set in 11 pt.
fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
scanned_word("Smith", 49.0, 78.0, 70.0, 9.0),
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
])
}
/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps
/// the width the scan shows it at (#3313).
#[test]
fn an_edited_ocr_phrase_is_drawn_at_one_size() {
let (mut s, id) = uneven_phrase();
let block = s.get(id).unwrap().text_blocks(0).remove(0);
assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["John", "Example"]);
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
// Each word keeps its width; the rest of the phrase closes up where Smith was.
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect);
assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John");
}
/// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and
/// a phrase whose size is NaN, zero, negative or infinite falls back to its words' sizes (#3313).
#[test]
fn only_an_ocr_phrase_with_a_usable_size_is_set_in_one_size() {
let (s, id) = uneven_phrase();
let block = s.get(id).unwrap().text_blocks(0).remove(0);
assert!(block.invisible);
assert_eq!(block.phrase_size(), Some(block.size));
let visible = pdfcraft_engine::TextBlock { invisible: false, ..block.clone() };
assert_eq!(visible.phrase_size(), None, "visible text keeps its runs' sizes");
for size in [f64::NAN, 0.0, -3.0, f64::INFINITY] {
assert_eq!(pdfcraft_engine::TextBlock { size, ..block.clone() }.phrase_size(), None, "a {size} pt phrase falls back to its words");
}
}
/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from
/// the first run: that is the phrase's size too, not the first word's guess.
#[test]
fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() {
let (mut s, id) = uneven_phrase();
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() };
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert!(!lines.is_empty());
for line in &lines {
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
}
}
/// Two neighbouring words OCR sized alike join into one run; the median still counts each word,
/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs.
#[test]
fn words_joined_into_one_run_each_count_toward_the_phrase_size() {
let (s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0),
scanned_word("JONES", 82.0, 112.0, 70.0, 9.0),
]);
let doc = s.get(id).unwrap();
assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run");
let size = doc.text_blocks(0)[0].size;
assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}");
}
/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan.
#[test]
fn a_word_far_larger_than_its_phrase_keeps_its_width() {
let (mut s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, 25.0),
scanned_word("Smith", 49.0, 78.0, 70.0, 10.0),
scanned_word("Example", 82.0, 124.0, 70.0, 10.5),
]);
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines[0].text.trim(), "John");
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
}
/// With a new font the phrase is one run in its first word's text state. How wide it is must not
/// depend on how far OCR misjudged that word's size: the same boxes give the same line.
#[test]
fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() {
let width = |first: f64| {
let (mut s, id) = scanned_with(vec![
scanned_word("John", 20.0, 45.0, 70.0, first),
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
]);
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() };
// Short enough not to wrap in the phrase's box either way.
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap();
let lines = s.get(id).unwrap().text_lines(0);
assert_eq!(lines.len(), 1, "one line: {lines:?}");
lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min)
};
let (guessed, right) = (width(15.0), width(11.0));
assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}");
}
/// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write.
fn stretched_visible() -> std::sync::Arc<Vec<u8>> {
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
+39 -3
View File
@@ -1,5 +1,5 @@
use super::Zip;
use super::as_html::esc;
use super::as_html::{esc, language_tag};
use super::layout::{Image, Item, Page, Table, body_size, items};
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
@@ -236,9 +236,40 @@ fn word_background(pages: &[Page]) -> Option<String> {
(same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2]))
}
/// The `w:lang` that makes `tag` the language of a Word document's text. Word keeps one language
/// per script: Chinese, Japanese and Korean are its East Asian text, Arabic, Hebrew, Persian and
/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. A
/// script the tag names (`pa-Arab`, `zh-Latn`) decides over its language.
fn word_lang(tag: &str) -> String {
let mut subtags = tag.split('-');
let primary = subtags.next().unwrap_or_default().to_ascii_lowercase();
// The script comes straight after the language and any three-letter extended languages; a
// singleton before it starts an extension or private use, which names no script.
let script = subtags
.find(|s| s.len() != 3 || !s.bytes().all(|b| b.is_ascii_alphabetic()))
.filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic()))
.map(str::to_ascii_lowercase);
let attribute = match (script.as_deref(), primary.as_str()) {
(Some("arab" | "hebr" | "syrc" | "thaa"), _) => "w:bidi",
(Some("hans" | "hant" | "hani" | "jpan" | "kore" | "hang" | "hira" | "kana"), _) => "w:eastAsia",
(Some("latn" | "cyrl" | "grek"), _) => "w:val",
(_, "zh" | "ja" | "ko") => "w:eastAsia",
(_, "ar" | "he" | "fa" | "ur") => "w:bidi",
_ => "w:val",
};
format!("<w:lang {attribute}=\"{}\"/>", esc(tag))
}
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
docx_with_language(pages, title, None)
}
/// [`docx`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives
/// it): the default language of its text, so Word checks the spelling in that language rather
/// than in its own. A language that is not a well formed tag is left out.
pub fn docx_with_language(pages: &[Page], title: &str, language: Option<&str>) -> Vec<u8> {
let mut body = String::new();
let mut media: Vec<(String, &Image)> = Vec::new();
// Word needs a paragraph between adjacent tables and after the last one in the body.
@@ -346,11 +377,16 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin
doc_rels.push_str("<Relationship Id=\"rIdSettings\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings\" Target=\"settings.xml\"/>");
}
doc_rels.push_str("</Relationships>");
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
let defaults = language_tag(language)
.map(|tag| format!("<w:docDefaults><w:rPrDefault><w:rPr>{}</w:rPr></w:rPrDefault></w:docDefaults>", word_lang(tag)))
.unwrap_or_default();
let styles = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{defaults}\
<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:pPr><w:spacing w:after=\"120\"/></w:pPr></w:style>\
<w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"240\"/><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
<w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"200\"/><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
</w:styles>";
</w:styles>"
);
let core = format!(
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
esc(title)
+53 -4
View File
@@ -8,6 +8,41 @@ pub(crate) fn allowed(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}')
}
/// The longest language tag the exports write. BCP 47 sets no maximum (35 characters is only
/// the least a protocol must hold), so this bounds only what a PDF's `/Lang` can put in a file.
const LONGEST_TAG: usize = 255;
/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary
/// language of two or three letters, then subtags of one to eight letters or digits, joined by
/// hyphens, [`LONGEST_TAG`] characters at most. A singleton (one letter or digit) starts an
/// extension, or with `x` private use, and needs a subtag after it. Anything else names no
/// language.
pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> {
let tag = lang?.trim();
let mut subtags = tag.split('-');
let primary = subtags.next()?;
if tag.len() > LONGEST_TAG || !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) {
return None;
}
// In private use every subtag is data, one character long or not.
let (mut private, mut singleton) = (false, false);
for s in subtags {
if !(1..=8).contains(&s.len()) || !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
return None;
}
if s.len() == 1 && !private {
if singleton {
return None;
}
private = s.eq_ignore_ascii_case("x");
singleton = true;
} else {
singleton = false;
}
}
(!singleton).then_some(tag)
}
pub(crate) fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars().filter(|c| allowed(*c)) {
@@ -38,17 +73,31 @@ pub(crate) fn base64(data: &[u8]) -> String {
/// One HTML file: headings and paragraphs, tables as real `<table>` elements, images inline, a
/// rule between pages.
pub fn html(pages: &[Page], title: &str) -> String {
html_with_mode(pages, title, false)
html_with_language(pages, title, None)
}
/// A simple HTML export with no CSS; page layout and colours are omitted.
pub fn html_simple(pages: &[Page], title: &str) -> String {
html_with_mode(pages, title, true)
html_simple_with_language(pages, title, None)
}
fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String {
/// [`html`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives
/// it), which the page names (`<html lang>`) for spell checking, speech and hyphenation. A
/// language that is not a well formed tag is left out.
pub fn html_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
html_with_mode(pages, title, false, language)
}
/// [`html_simple`] for a document in `language`, named as [`html_with_language`] names it: a
/// language is not styling, so the simple export keeps it.
pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
html_with_mode(pages, title, true, language)
}
fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String {
let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default();
let mut s = format!(
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
"<!DOCTYPE html>\n<html{lang}>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
esc(title),
if simple {
""
+2 -2
View File
@@ -23,14 +23,14 @@ mod as_rtf;
mod clip;
mod layout;
pub use as_docx::docx;
pub(crate) use as_docx::hex;
#[cfg(test)]
use as_docx::{MAX_MEDIA, docx_table};
pub use as_docx::{docx, docx_with_language};
pub(crate) use as_html::esc;
#[cfg(test)]
use as_html::{allowed, base64};
pub use as_html::{html, html_simple};
pub use as_html::{html, html_simple, html_simple_with_language, html_with_language};
pub use as_markdown::markdown;
#[cfg(test)]
use as_markdown::{emphasis, paragraph};
+55
View File
@@ -247,6 +247,61 @@ pub(crate) fn part(zip: &[u8], name: &str) -> String {
panic!("{name} missing")
}
/// The PDF's language (`/Lang`) is the default language of the Word document's text, in the
/// attribute Word keeps for its script, and the HTML page's. No language, or one that is not a
/// well formed tag, leaves both files as they were.
#[test]
fn word_and_html_take_the_documents_language() {
let pages = [page()];
let styles = |lang| part(&docx_with_language(&pages, "Report", lang), "word/styles.xml");
let defaults = |lang: &str| format!("<w:docDefaults><w:rPrDefault><w:rPr>{lang}</w:rPr></w:rPrDefault></w:docDefaults>");
assert!(styles(Some("bg-BG")).contains(&defaults("<w:lang w:val=\"bg-BG\"/>")), "{}", styles(Some("bg-BG")));
assert!(styles(Some(" en-GB ")).contains("<w:lang w:val=\"en-GB\"/>"), "the tag is trimmed");
assert!(styles(Some("zh-Hant-TW")).contains(&defaults("<w:lang w:eastAsia=\"zh-Hant-TW\"/>")), "East Asian text");
assert!(styles(Some("ja")).contains("<w:lang w:eastAsia=\"ja\"/>"), "East Asian text");
assert!(styles(Some("ar-EG")).contains(&defaults("<w:lang w:bidi=\"ar-EG\"/>")), "right-to-left text");
assert!(styles(Some("HE")).contains("<w:lang w:bidi=\"HE\"/>"), "right-to-left text");
// A script subtag decides over the language; private use names no script.
assert!(styles(Some("pa-Arab-PK")).contains("<w:lang w:bidi=\"pa-Arab-PK\"/>"), "Punjabi in Arabic script");
assert!(styles(Some("sr-Latn")).contains("<w:lang w:val=\"sr-Latn\"/>"), "Serbian in Latin script");
assert!(styles(Some("zh-Latn")).contains("<w:lang w:val=\"zh-Latn\"/>"), "Chinese in Latin script");
assert!(styles(Some("en-x-arab")).contains("<w:lang w:val=\"en-x-arab\"/>"), "private use");
assert!(styles(Some("az-Latn-x-arab")).contains("<w:lang w:val=\"az-Latn-x-arab\"/>"), "private use");
assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("<!DOCTYPE html>\n<html lang=\"bg-BG\">\n"));
// A tag longer than 35 characters is still a tag.
let long = "en-US-u-ca-gregory-nu-latn-co-phonebk";
assert!(styles(Some(long)).contains(&format!("<w:lang w:val=\"{long}\"/>")), "{long}");
assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("<html lang=\"{long}\">")), "{long}");
let too_long = format!("en{}", "-abcdefgh".repeat(29));
// Private use takes one-character subtags.
assert!(html_with_language(&pages, "Report", Some("en-x-a-b")).contains("<html lang=\"en-x-a-b\">"));
for lang in [
None,
Some(""),
Some(" "),
Some("bg_BG"),
Some("b"),
Some("bulg-BG"),
Some("bg--BG"),
Some("bg-"),
Some("en-x"),
Some("en-a"),
Some("en-a-x-private"),
Some("en-US-u-ca-x"),
Some("en-US-abcdefghi"),
Some(too_long.as_str()),
Some("en-\"x\""),
Some("1a-BG"),
Some("\u{431}\u{433}-BG"),
] {
assert!(docx_with_language(&pages, "Report", lang) == docx(&pages, "Report"), "{lang:?}");
assert_eq!(html_with_language(&pages, "Report", lang), html(&pages, "Report"), "{lang:?}");
assert_eq!(html_simple_with_language(&pages, "Report", lang), html_simple(&pages, "Report"), "{lang:?}");
}
// The simple export leaves out styling, not the language.
assert!(html_simple_with_language(&pages, "Report", Some("bg-BG")).starts_with("<!DOCTYPE html>\n<html lang=\"bg-BG\">\n"));
}
#[test]
fn word_opens_text_with_control_characters_and_long_pages() {
// #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids;
+2
View File
@@ -14,6 +14,8 @@ workspace = true
pdfcraft-fonts = { workspace = true }
ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }
rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }
serde_json.workspace = true
sha2 = "0.11"
thiserror.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
+138 -1
View File
@@ -29,7 +29,9 @@ pub mod tesseract;
pub use confidence::{ConfidenceBand, band_for, is_suspect, low_confidence};
pub use merge::{MergeStrategy, StrategyOutcome, mean_confidence, recognize_with_strategy, rover_merge};
pub use ocrs::{ALPHABET, DETECTION_MODEL, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL};
pub use ocrs::{
ALPHABET, DETECTION_MODEL, DETECTION_MODEL_SHA256, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256,
};
pub use pdfcraft_fonts::TextPlan;
pub use pdfcraft_fonts::helvetica_width;
@@ -464,6 +466,58 @@ pub fn text_layer(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan) -> Vec<
out
}
/// The recognised lines as JSON, `[[["word", left, top, right, bottom], …], …]`, with the
/// word's confidence as a sixth item when it has one: how the web build's OCR workers send what
/// they found back to the page.
pub fn lines_to_json(lines: &[Line]) -> String {
let word = |w: &Word| {
let mut items = vec![serde_json::json!(w.text), w.rect[0].into(), w.rect[1].into(), w.rect[2].into(), w.rect[3].into()];
items.extend(w.confidence.map(serde_json::Value::from));
serde_json::Value::Array(items)
};
serde_json::Value::Array(lines.iter().map(|l| serde_json::Value::Array(l.words.iter().map(word).collect())).collect()).to_string()
}
/// At most this many words come back for one page (a dense page has a few thousand).
const MAX_JSON_WORDS: usize = 100_000;
/// [`lines_to_json`] read back, the words credited to `source` (the engine the worker ran).
/// Anything else (a word without its box, a box or confidence that isn't a finite number, too
/// many words) is an error rather than a guess.
pub fn lines_from_json(json: &str, source: &str) -> Result<Vec<Line>, OcrError> {
let bad = |why: &str| OcrError::Recognize(format!("unreadable result: {why}"));
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| bad(&e.to_string()))?;
let lines = value.as_array().ok_or_else(|| bad("not a list of lines"))?;
let number = |v: &serde_json::Value| v.as_f64().map(|v| v as f32).filter(|v| v.is_finite());
let mut count = 0usize;
let mut out = Vec::with_capacity(lines.len().min(MAX_JSON_WORDS));
for line in lines {
let mut words = Vec::new();
for word in line.as_array().ok_or_else(|| bad("a line is not a list of words"))? {
count += 1;
if count > MAX_JSON_WORDS {
return Err(bad("too many words"));
}
let (text, rect, confidence) = match word.as_array().map(Vec::as_slice) {
Some([serde_json::Value::String(text), l, t, r, b]) => (text, [l, t, r, b], None),
Some([serde_json::Value::String(text), l, t, r, b, c]) => (text, [l, t, r, b], Some(c)),
_ => return Err(bad("a word is not [text, left, top, right, bottom, confidence?]")),
};
let mut r = [0f32; 4];
for (slot, v) in r.iter_mut().zip(rect) {
*slot = number(v).ok_or_else(|| bad("a word's box is not finite numbers"))?;
}
let mut w = Word::new(text.clone(), r, source);
if let Some(c) = confidence {
w = w.with_confidence(number(c).ok_or_else(|| bad("a word's confidence is not a finite number"))?);
}
words.push(w);
}
out.push(Line { words });
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -854,6 +908,89 @@ mod tests {
assert_eq!(ok.gray(-1, -1), Some(255));
}
/// The web build loads the models from downloaded bytes; they read the same text.
#[test]
fn reads_rendered_text_with_models_from_bytes() {
let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else {
eprintln!("skipped: ONNX OCR models not installed");
return;
};
let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap();
let rec = OcrsRecognizer::from_ocr(ocr);
assert!(rec.available(), "a recogniser loaded from bytes needs no model files");
let (w, h, px) = test_image::hello();
let lines = rec.recognize(&OcrImage::new(w, h, px).unwrap(), &RecognizeOptions::default()).unwrap();
let text: Vec<String> = lines.iter().map(Line::text).collect();
assert!(text.iter().any(|t| t.contains("HELL") && t.contains("WOR")), "{text:?}");
}
/// The web build's workers send lines back as JSON; they arrive unchanged, credited to the
/// engine the worker ran.
#[test]
fn lines_survive_json() {
let lines = vec![
Line {
words: vec![
Word::new("Total \"€250\"", [1.5, 2.0, 30.25, 12.0], "ocrs"),
Word::new("due", [32.0, 2.0, 50.0, 12.0], "ocrs").with_confidence(87.5),
],
},
Line::default(),
Line { words: vec![Word::new("日本語", [0.0, 20.0, 9.0, 30.0], "ocrs")] },
];
assert_eq!(lines_from_json(&lines_to_json(&lines), "ocrs").unwrap(), lines);
assert_eq!(lines_from_json(&lines_to_json(&[]), "ocrs").unwrap(), Vec::<Line>::new());
let other = lines_from_json(&lines_to_json(&lines), "tesseract").unwrap();
assert!(other.iter().flat_map(|l| &l.words).all(|w| w.source == "tesseract"));
}
/// A result that isn't what a worker sends is an error, not a crash or a guess.
#[test]
fn malformed_json_lines_are_an_error() {
for json in [
"",
"{}",
"[1]",
"[[1]]",
"[[[\"a\", 1, 2, 3]]]",
"[[[\"a\", 1, 2, 3, 4, 5, 6]]]",
"[[[1, 1, 2, 3, 4]]]",
"[[[\"a\", \"1\", 2, 3, 4]]]",
"[[[\"a\", 1e999, 2, 3, 4]]]",
"[[[\"a\", null, 2, 3, 4]]]",
"[[[\"a\", 1, 2, 3, 4, null]]]",
"[[[\"a\", 1, 2, 3, 4, \"90\"]]]",
] {
assert!(matches!(lines_from_json(json, "ocrs"), Err(OcrError::Recognize(_))), "{json}");
}
let many = format!("[[{}]]", vec!["[\"a\",0,0,1,1]"; MAX_JSON_WORDS + 1].join(","));
assert!(lines_from_json(&many, "ocrs").is_err());
let deep = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
assert!(lines_from_json(&deep, "ocrs").is_err());
}
/// A download that isn't a model (an HTML error page, a truncated file) is an error, not a
/// crash or a hang.
#[test]
fn models_from_bad_bytes_are_an_error() {
for (detection, recognition) in [(Vec::new(), Vec::new()), (b"<!doctype html>".to_vec(), b"RTEN".to_vec()), (vec![0xff; 64], vec![0; 64])] {
assert!(matches!(Ocr::from_bytes(detection, recognition), Err(OcrError::Load(..))));
}
}
/// The hashes [`Ocr::from_bytes`] checks are the ones `cargo xtask models` verifies.
#[test]
fn model_hashes_match_attribution() {
let manifest = include_str!("../../../ATTRIBUTION.toml");
for (file, sha256) in [(DETECTION_MODEL, DETECTION_MODEL_SHA256), (RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256)] {
let entry = manifest
.split("[[fetched]]")
.find(|e| e.contains(&format!("file = \"{file}\"")))
.unwrap_or_else(|| panic!("{file} is not in ATTRIBUTION.toml"));
assert!(entry.lines().any(|l| l == format!("sha256 = \"{sha256}\"")), "{file}: SHA-256 differs from ATTRIBUTION.toml");
}
}
mod test_image {
/// "HELLO WORLD" drawn with blocky 5×7 letters, 4 pixels per dot, black on white.
pub fn hello() -> (u32, u32, Vec<u8>) {
+38 -4
View File
@@ -32,6 +32,15 @@ pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")];
/// The model files, as named in ATTRIBUTION.toml.
pub const DETECTION_MODEL: &str = "text-detection.onnx";
pub const RECOGNITION_MODEL: &str = "text-recognition.onnx";
/// Their SHA-256s, as in ATTRIBUTION.toml. [`Ocr::from_bytes`] loads nothing else: rten's ONNX
/// parser can spin forever on bytes that aren't a model (64 × 0xff does).
pub const DETECTION_MODEL_SHA256: &str = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe";
pub const RECOGNITION_MODEL_SHA256: &str = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5";
fn sha256_hex(bytes: &[u8]) -> String {
use sha2::{Digest, Sha256};
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
}
pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
@@ -84,9 +93,25 @@ pub struct Ocr {
impl Ocr {
pub fn load(models: &Models) -> Result<Ocr, OcrError> {
let load = |p: &Path| rten::Model::load_file(p).map_err(|e| OcrError::Load(p.display().to_string(), e.to_string()));
Self::with_models(load(&models.detection)?, load(&models.recognition)?)
}
/// Load the models from the contents of their files, where there is no file system (the web
/// build downloads them). Bytes that aren't the files ATTRIBUTION.toml names are an error.
pub fn from_bytes(detection: Vec<u8>, recognition: Vec<u8>) -> Result<Ocr, OcrError> {
let load = |name: &str, sha256: &str, data: Vec<u8>| {
if sha256_hex(&data) != sha256 {
return Err(OcrError::Load(name.into(), "not the expected model file (SHA-256 differs)".into()));
}
rten::Model::load(data).map_err(|e| OcrError::Load(name.into(), e.to_string()))
};
Self::with_models(load(DETECTION_MODEL, DETECTION_MODEL_SHA256, detection)?, load(RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256, recognition)?)
}
fn with_models(detection: rten::Model, recognition: rten::Model) -> Result<Ocr, OcrError> {
let params = ocrs::OcrEngineParams {
detection_model: Some(load(&models.detection)?),
recognition_model: Some(load(&models.recognition)?),
detection_model: Some(detection),
recognition_model: Some(recognition),
alphabet: Some(ALPHABET.into()),
..Default::default()
};
@@ -139,12 +164,21 @@ impl Ocr {
/// The ocrs engine as a [`Recognizer`] (the default engine).
pub struct OcrsRecognizer {
engine: Ocr,
/// Loaded from bytes ([`OcrsRecognizer::from_ocr`]), not from model files: it stays
/// available without them.
in_memory: bool,
}
impl OcrsRecognizer {
/// Load the models from `models`.
pub fn load(models: &Models) -> Result<OcrsRecognizer, OcrError> {
Ok(OcrsRecognizer { engine: Ocr::load(models)? })
Ok(OcrsRecognizer { engine: Ocr::load(models)?, in_memory: false })
}
/// A recogniser loaded some other way than from model files: the web build downloads the
/// models and loads them with [`Ocr::from_bytes`].
pub fn from_ocr(engine: Ocr) -> OcrsRecognizer {
OcrsRecognizer { engine, in_memory: true }
}
/// Load the models found by [`Models::find`].
@@ -159,7 +193,7 @@ impl Recognizer for OcrsRecognizer {
}
fn available(&self) -> bool {
Models::find().is_some()
self.in_memory || Models::find().is_some()
}
fn languages(&self) -> Vec<(String, String)> {
+1 -1
View File
@@ -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 }
+9
View File
@@ -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(),
@@ -642,6 +646,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);
+5
View File
@@ -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,
+11
View File
@@ -187,8 +187,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;
@@ -524,6 +528,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).
+43
View File
@@ -1345,6 +1345,49 @@ C", &picked
assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::<String>(), "Text preview");
}
/// Typing in a recognised phrase whose words OCR sized at 15, 9 and 11 pt lays every word out
/// at the phrase's one size, as the engine redraws it (#3313); each word used its own.
#[test]
fn typing_in_an_ocr_phrase_lays_every_word_out_at_one_size() {
use pdfcraft_engine::ocr::PlacedWord;
// A word box as OCR draws it around capitals: the cap height of a `size` pt word.
let word = |text: &str, x0: f64, x1: f64, size: f64| PlacedWord {
text: text.into(),
origin: [x0, 70.0],
across: [x1 - x0, 0.0],
up: [0.0, 0.718 * size],
confidence: None,
source: "ocrs".into(),
};
let mut app = crate::PdfCraftApp::new();
let png = pdfcraft_engine::export::encode_png(200, 100, &[235u8; 200 * 100 * 4]).unwrap();
let bytes = app.session.create_from_images(&[("scan.png".into(), png)]).unwrap();
app.open_bytes("scan.pdf", None, bytes.as_ref().clone()).unwrap();
let id = app.views[0].id;
let words = vec![word("JOHN", 20.0, 45.0, 15.0), word("SMITH", 49.0, 78.0, 9.0), word("EXAMPLE", 82.0, 124.0, 11.0)];
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
let doc = app.session.get(id).unwrap();
let block = doc.text_blocks(0).remove(0);
assert!(block.invisible && block.lines.len() == 3, "one phrase of three recognised words: {block:?}");
let (_, mut ed) = preview_app();
ed.text = format!("{}S", block.editor_text);
ed.original = block.editor_text.clone();
ed.look = look_of(&block);
ed.look0 = look_of(&block);
ed.source = Some((block, doc.text_lines(0)));
let ctx = egui::Context::default();
let mut sizes = Vec::new();
ctx.run_ui(egui::RawInput::default(), |ui| {
let job = editor_job(ui, &ed, &ed.text, 1.0, f32::INFINITY);
sizes = job.sections.iter().map(|section| section.format.font_id.size).collect();
})
.textures_delta
.clear();
assert!(sizes.len() >= 3, "a section for each word: {sizes:?}");
assert!(sizes.iter().all(|size| (size - 11.0).abs() < 0.05), "every word at the phrase's 11 pt: {sizes:?}");
}
#[test]
fn a_refused_paragraph_stays_open_with_its_text() {
let (mut app, mut ed) = preview_app();
@@ -126,10 +126,14 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
if centered {
job.halign = egui::Align::Center;
}
// An OCR phrase is set in one size, as the engine redraws it: each word's own is a guess
// from its box. Its words still keep their widths.
let phrase = block.phrase_size();
for (range, index) in block.edited_spans(text) {
let Some(line) = lines.get(index) else { continue };
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
look.size = line.size;
let base = phrase.unwrap_or(line.size);
let mut look = source_look(&line.base_font, base, line.color, line.bold, line.italic);
look.size = base;
if ed.look.family != ed.look0.family || ed.family_chosen {
look.family = ed.look.family;
}
@@ -153,7 +157,7 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
font.family = FontFamily::Name(name.clone().into());
}
let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x);
let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale;
let target_width = ((line.rect[2] - line.rect[0]) * look.size / base.max(0.01)) as f32 * scale;
let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 };
job.append(
text.get(range).unwrap_or_default(),
+4
View File
@@ -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;
+139
View File
@@ -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());
}
}
+247 -23
View File
@@ -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")]
pub(crate) 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, &notes, 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]
+118
View File
@@ -219,6 +219,15 @@ pub struct Reread {
pub(crate) progress: Arc<Mutex<RereadProgress>>,
}
/// 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)]
@@ -255,6 +264,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<Reread>,
/// A run or re-read waiting for the web build's model download.
pub(crate) awaiting_models: Option<AwaitingModels>,
/// The correction text field's current contents.
pub correction: String,
/// Where the per-language dictionary files live (`None`: off, e.g. the web build).
@@ -307,6 +318,7 @@ impl Default for OcrVerify {
cancelled: false,
progress: None,
reread: None,
awaiting_models: None,
correction: String::new(),
dictionary_dir: None,
dictionary_language: "en".into(),
@@ -919,6 +931,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");
@@ -973,6 +990,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) {
@@ -1158,6 +1220,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 };
let language = job.settings.language.clone();
let guard = job.guard.clone();
@@ -2576,6 +2643,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() {
+521
View File
@@ -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.
runs: 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, runs: 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.runs.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.runs.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 -1
View File
@@ -89,7 +89,9 @@ On a scanned page, recognised text (Scan & OCR) is an invisible layer over the p
text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each
scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or
the one chosen) and first covers the old words with the paper colour sampled around them. Each
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping.
OCR sizes each word from its own box, so a phrase's words can come out several points apart: the
editor and the rewrite set the whole phrase in one size, the median of its words' sizes. The
cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing
invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a
+4 -2
View File
@@ -1,6 +1,6 @@
# Hardware and platform parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (web OCR shipped: Web Workers with SIMD, checked in a browser; earlier: web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS
and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows,
@@ -34,12 +34,14 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | — |
| Linux | no | yes | AppImage, deb, rpm, Flatpak, tarball | Chinese faces in Flatpak (#689) |
| FreeBSD | no | yes | package | — |
| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR, crash recovery, PKCS #12 signing, hosted deployment |
| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR as fast as native (Web Workers with SIMD read a page about 17× slower), crash recovery, PKCS #12 signing, hosted deployment |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check |
| 2026-10-12 | minor | Web platform row: OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, partial); what's left is speed |
| 2026-10-11 | minor | Web printers row: printing through the browser's print dialog (gap 4 closed); Web platform row no longer lists printing |
| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues |
| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" |
+4 -1
View File
@@ -249,7 +249,7 @@ total matches the parity estimate.
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
@@ -316,6 +316,7 @@ Estimates unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. |
| 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier |
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
@@ -386,6 +387,8 @@ Estimates unchanged.
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
| 2026-10-14 | minor | M12: pending page backgrounds respect reading colours (#2059); estimates unchanged. |
| 2026-10-12 | minor | M10 `ocr.ocr-web` shipped: Web Worker OCR checked by hand in a browser. Estimates unchanged. |
| 2026-10-12 | minor | M10 `ocr.ocr-web` (partial): the web build downloads the OCR models on first use (the web zip ships them) and reads pages in Web Workers built with SIMD. Estimates unchanged. |
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
+3 -1
View File
@@ -191,7 +191,7 @@ features total and are shown for orientation; the others add to it.
| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) |
| Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) |
| Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) |
| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) |
| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; web OCR reads in Web Workers but is far slower than native, and we lack web recovery | [hardware-parity.md](hardware-parity.md) |
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) |
| AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) |
| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | |
@@ -303,6 +303,8 @@ licensed OCR or AI model.
| 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. |
| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. |
| 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. |
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check; scores not re-judged |
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`, partial): the web zip serves the models, Recognize Text downloads them on first use, and pages are read in Web Workers built with SIMD. Platforms text updated; scores not re-judged |
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
| 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged |
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
+26
View File
@@ -8,7 +8,9 @@ static site in `pdfcraft-web-<version>/`:
| `index.html` | The page. It loads everything through relative URLs. |
| `pdfcraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
| `pdfcraft-web-<hash>_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
| `pdfcraft-ocr-worker-<hash>.js`, `_bg.wasm`, `_loader.js` | The text recognition worker (about 3 MB), started only when OCR runs |
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
| `models/` | The text recognition (OCR) models, about 12 MB, with their licences (CC-BY-SA-4.0) |
There is no server-side code. Upload the folder's contents anywhere that serves static files.
@@ -55,6 +57,26 @@ location /pdfcraft/ {
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
## Text recognition (OCR)
Scan & OCR ▸ Recognize Text downloads `models/text-detection.onnx` and
`models/text-recognition.onnx`, relative to the page, the first time it is opened in a visit.
Keep `models/` beside `index.html`. The app loads only the files whose SHA-256 matches
`ATTRIBUTION.toml`, so a wrong file fails like a missing one. Their file names carry no content
hash, so the app asks the server before using a cached copy (`no-cache`): the server must answer
that check correctly (an ETag, or a `Last-Modified` that changes whenever the file does). Without
the models, everything else works and the Recognize Text dialog says the download failed.
Pages are read in Web Workers (`pdfcraft-ocr-worker-<hash>_loader.js`, a module worker), so the
app keeps responding, and two pages are read at once on a machine with three or more cores. Each
worker needs about 600 MB while it reads; idle workers close after a minute. The workers need no
special headers, but a `Content-Security-Policy` must allow them (`worker-src 'self'`, or
`script-src 'self'`). Where they can't start, the app reads the pages itself, a page at a time,
and doesn't respond while each page is read.
From a source build, `trunk build --release` doesn't fetch the models. Add them with
`cargo xtask models dist/web/models` (after the build, which replaces `dist/web`).
## Embedding in a page (iframe)
```html
@@ -162,3 +184,7 @@ PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls
For example: `<iframe src="https://example.com/pdfcraft/?webgl" ...>`.
A browser with neither WebGPU nor WebGL2 gets a message in place of the app.
The build uses WebAssembly SIMD (`.cargo/config.toml`), which reads a scanned page about 2.4
times faster. Every current browser has it (Chrome and Edge 91, Firefox 89, Safari 16.4 and
later). Older browsers can't load the app.
+6 -3
View File
@@ -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"
+11 -11
View File
@@ -6696,7 +6696,7 @@ status = "shipped"
commands = ["ocr.recognize"]
tools = ["ocr_recognize", "ocr_status"]
evidence = ["crates/ocr/src/ocrs.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/ocrs.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/ocrs.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/ocrs.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]
notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), and the web zip serves them for download (ocr.ocr-web); runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)."
[[feature]]
id = "ocr.recognize-range"
@@ -6767,8 +6767,8 @@ milestone = "M10"
status = "partial"
commands = ["edit.edit_text", "ocr.correct"]
tools = ["text_edit", "ocr_recognize"]
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping. Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
evidence = ["crates/engine/tests/it/ocr_edit.rs::editing_an_ocr_word_covers_the_scan_and_shows_the_new_text", "crates/engine/tests/it/ocr_edit.rs::an_explicit_colour_is_kept_for_the_new_text", "crates/engine/tests/it/ocr_edit.rs::ocr_words_group_into_phrases_that_edit_in_place", "crates/engine/tests/it/ocr_edit.rs::a_longer_ocr_phrase_grows_rightwards", "crates/engine/tests/it/ocr_edit.rs::a_rewritten_ocr_phrase_keeps_its_word_widths", "crates/engine/tests/it/ocr_edit.rs::an_edited_ocr_phrase_is_drawn_at_one_size", "crates/engine/tests/it/ocr_edit.rs::an_ocr_layer_under_the_scan_image_shows_its_edit", "crates/engine/tests/it/ocr_edit.rs::an_ocr_phrase_split_from_visible_text_under_the_scan_shows_its_edit", "crates/ui-egui/tests/it/ocr.rs::an_ocr_word_opens_for_editing_and_stays_open", "crates/ui-egui/tests/it/ocr.rs::editing_a_scanned_page_recognizes_its_text_first_and_only_once", "crates/engine/src/ocr.rs::editable_writer_refuses_empty_and_writes_visible_words", "crates/engine/src/ocr.rs::editable_output_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::editable_writer_maps_rotated_pages", "crates/automation/tests/it/automation.rs::ocr_recognize_editable_output_writes_visible_text"]
notes = "On demand, per edit: in Edit text & images, recognised text (the invisible OCR layer over a scan) is grouped into phrases (words across a word space; a wider gap such as a table cell, and each scanned line, stay separate) and opens for editing on a click. Applying draws the new text visibly in the scan's sampled ink colour (or the chosen one) and first covers the old words with the paper colour sampled around them, so the scanned word no longer shows; when an image or form is drawn after the OCR layer over where the edit lands (its old box, moved, rewrapped or grown to more lines), or an inline image or shading after it (a layer written under its scan, #3973), the cover and new text are drawn on top of the page instead, so they show; the phrase keeps each word's position and width and grows rightwards instead of wrapping, and is set in one size, the median of its words' (OCR sizes each word from its own box, several points apart), both while typing and when applied (#3313). Turning Edit text & images on for a scanned page (an image covering at least half of a page that has no text of its own, once per page per window; comments and text boxes over it do not count as text, a page with real text and a background picture is left alone; one undoable \"Recognize text\" step) now recognizes its words first, as Acrobat's \"Converting scanned page to Editable Text and Images\" does (English only until more OCR languages exist, gap 7). Still missing: separating the photographs on the page from the lettering, matching the scanned typeface (the layer's Helvetica is used), covering on a textured or patterned background (one flat colour is used), a scan painted by a pattern fill or a filled rectangle over a layer written under it, and, for text drawn on top, the clip, optional content and marked content (tags) that enclosed the OCR layer."
[[feature]]
id = "ocr.ocr-downsample"
@@ -6956,11 +6956,11 @@ title = "OCR in the browser"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
status = "shipped"
commands = ["ocr.recognize"]
tools = []
evidence = []
notes = ""
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"
@@ -7203,8 +7203,8 @@ milestone = "M10"
status = "partial"
commands = ["export.docx"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence"]
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity."
evidence = ["crates/export/src/tests.rs::word_table_cell_widths_follow_the_columns_they_span", "crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/edit/src/tests.rs::reading_keeps_dotted_leader_entries_whole_and_in_order", "crates/engine/src/tests.rs::exporting_office_keeps_dotted_leader_entries_as_paragraphs", "crates/export/src/tests.rs::word_does_not_invent_borders_for_aligned_invoice_fields", "crates/export/src/tests.rs::word_overlap_does_not_consume_invoice_body_space", "crates/export/src/tests.rs::word_keeps_a_page_with_a_clear_image_and_big_title_in_flow", "crates/export/src/tests.rs::word_rotated_pages_keep_separate_page_sizes_and_unique_shapes", "crates/engine/src/tests.rs::office_export_positions_follow_the_displayed_crop_rotation_and_units", "crates/automation/tests/it/automation.rs::exporting_an_invoice_to_word_keeps_the_watermark_out_of_flow", "crates/export/src/tests.rs::docx_is_a_word_package", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/engine/src/tests.rs::exporting_office_files_keeps_text_colour", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/export/src/tests.rs::a_link_inside_a_sentence_is_not_a_table_column", "crates/export/src/tests.rs::sentences_that_run_on_into_links_are_not_a_table", "crates/export/src/tests.rs::a_superscript_beside_a_number_keeps_its_table", "crates/export/src/tests.rs::word_opens_text_with_control_characters_and_long_pages", "crates/engine/src/tests.rs::exporting_office_files_keeps_images", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/export/src/tests.rs::docx_keeps_a_shared_page_background", "crates/edit/src/tests.rs::a_page_painted_first_with_a_full_page_fill_has_that_background", "crates/engine/src/tests.rs::exporting_to_word_keeps_the_page_background", "crates/export/src/tests.rs::an_italic_word_stays_in_its_sentence", "crates/export/src/tests.rs::a_sentence_keeps_its_reading_order", "crates/export/src/tests.rs::runs_keep_the_spaces_the_source_had_between_them", "crates/export/src/tests.rs::a_paragraph_is_a_heading_only_when_its_text_is", "crates/automation/tests/it/automation.rs::exporting_to_word_keeps_an_italic_word_inside_its_sentence", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"]
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Flowing Word document: paragraphs, Heading 1/2 from larger text, bold/italic, text colour (fill as sRGB; gray/CMYK converted naively, colour spaces via cs/sc read as black), detected tables with spans/empty cells (borderless, except single borders along the grid lines the page draws, #5149) (cell widths sum the grid columns they occupy, including cells after spans, verified in the XML; a row whose blocks run on into each other, such as a link or superscript inside a sentence, or overlap, as a paragraph's lines do, is text and not a table row, #2806), per-page sections/sizes, the page colour (a full-page opaque fill painted first, such as Edit a PDF ▸ Background) as the document background when every page shares one non-white colour (Word has no per-page background, so differing colours are dropped; #2992; checked in the XML only, not yet opened in Word), and positioned editable text/artwork only for pages with rotated text or text overlapped by an image or watermark-sized text, other pages flowing with inline images (#773); synthetic invoice regression; dotted-leader contents entries keep the title, dots and page number together in separate paragraphs (#1757); the emitted package validates with Open XML SDK 2.20 (Office 2010 schema). Word/LibreOffice rendering of the positioned output remains unverified. Pictures on positioned pages are DrawingML anchors behind the text (a VML rectangle with an image showed no picture, #1936; not yet opened in Word); a Word file embeds at most 1000 pictures and 512 MiB of them. The earlier flowing output was checked with macOS textutil and opened in Microsoft Word (Windows). Characters XML forbids are dropped; the page size stays within Word's 22 in. A block that continues another on its line (an italic case name, a link, a superscript) is a run in its own size, bold/italic and colour inside that paragraph, keeping the source's word space between them, rather than a paragraph of its own; a lead-in in bold no longer makes its whole line a heading (#4158). The PDF's language (`/Lang`, when it is a well formed BCP 47 tag) is the default language of the text, in the attribute Word keeps for its script (East Asian, right to left or other), so Word checks the spelling in it rather than in English (checked in the XML only, not yet opened in Word). Not yet: a style change that wraps onto the next line still splits its paragraph there (the reading blocks keep style boundaries, #4158), exact font matching, vector rules other than a table grid, arbitrary paint-order/transparency, general exact layout fidelity."
[[feature]]
id = "create.export-rtf"
@@ -7327,8 +7327,8 @@ milestone = "M10"
status = "partial"
commands = ["export.html"]
tools = ["doc_export_office"]
evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops"]
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts."
evidence = ["crates/export/src/tests.rs::table_spans_do_not_consume_text_they_do_not_emit", "crates/export/src/tests.rs::html_has_headings_paragraphs_and_images_in_order", "crates/export/src/tests.rs::text_colour_survives_every_format", "crates/export/src/tests.rs::grid_text_becomes_a_real_table_in_every_format", "crates/automation/tests/it/automation.rs::exporting_to_word_html_and_rtf", "crates/ui-egui/tests/it/compare.rs::export_to_word_html_and_rtf", "crates/automation/tests/it/automation.rs::exporting_office_files_says_which_pages_left_form_text_out", "crates/edit/src/tests.rs::reading_follows_forms_as_far_as_the_renderer_and_says_where_it_stops", "crates/export/src/tests.rs::word_and_html_take_the_documents_language", "crates/engine/src/tests.rs::office_exports_take_the_documents_language"]
notes = "Text in slots covered by a preceding table-cell span remains in paragraph flow instead of being discarded. Single HTML file with headings, paragraphs (with text colour), inline images, and detected tables with spans/empty cells. The page names the PDF's language (`<html lang>`, from a well formed `/Lang`). Text and images in form XObjects are read 50 levels deep, as the renderer does, and up to 50 000 forms a page; the tool lists pages past that in truncated_pages, with a warning (#3120). Not yet: multi-page folder output, CSS from fonts."
[[feature]]
id = "create.export-text"
+2 -1
View File
@@ -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());
}
}
+6
View File
@@ -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<()> {
+145
View File
@@ -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());
}
}