M10: OCR engines, confidence, preprocessing and a review workflow (#977)

* M10.20: plan Unicode text for OCR output in Type3 faces (fonts, edit)

OCR output used to flatten every character beyond WinAnsi to a silent `?`. This
adds the two pieces that let the OCR layers write the recognized words as they
are; the OCR code that uses them follows in M10.21.

pdfcraft-fonts
- `TextPlan::for_words` maps each word to the standard Helvetica when WinAnsi
  covers it, else to Type3 fonts drawn from the craft-fonts faces (the Japanese
  document face first, then the rest of the build input). Characters no face
  has a glyph for are counted (`missing`), never dropped silently. At most 240
  characters per font and 2400 per plan, so one hostile word cannot balloon.
- Which face has which character is asked once per (face, character) for a plan,
  not once per word.
- `face_has`, `craft_glyph`, `win_ansi_char`, `fits_win_ansi` are public for it.

pdfcraft-edit
- `add_type3_fonts` / `stamp_with_fonts` register a plan's fonts on a page under
  a free `PCOcr`, `PCOcr1`, ... name, build each font once and share the object
  with later pages. A shared face is registered under a free name on a later
  page that already has a font of the name it was given first, instead of
  replacing that page's own font.
- The fonts carry real advances and a ToUnicode CMap, so extraction reads the
  text back.

pdfcraft-redact
- A test shows a hand-built Type3 text layer (the shape the OCR output writes) is
  redacted like any other text and the verifier passes.

Tests: pdfcraft-fonts 62 passed, pdfcraft-edit 129 passed, pdfcraft-redact 33
passed; clippy -D warnings clean on the three crates. The name-reuse and the
redaction tests build their fonts by hand, so they run without the craft-fonts
build input; the tests that plan real faces skip without it and were not run in
this commit's own check.

* M12: give the hostile-XFA open test more room on a loaded machine

The test opens a form whose calculate script sets a value 50 000 times, against
the one-second budget of an unasked script and a five-second wall-clock bound
on the open. Both sit close to what a machine running several suites at once
can take, so the test can fail without anything being wrong.

It now runs 20 000 iterations (the total is 19999) and allows 30 seconds, which
still fails for a script that hangs or is not capped. Test-only; unrelated to
the OCR work in this series, so it is a commit of its own.

Tests: both hostile_xfa tests in pdfcraft-engine pass.

* M10.21: pluggable OCR engines, preprocessing, review sessions and the OCR tools (ocr, engine, create, automation)

Scan & OCR gets a second engine, optional raster clean-up, a review step before
anything is applied, editable output and a batch mode, headlessly first: the
interface for them follows in M10.22.

pdfcraft-ocr
- A `Recognizer` trait with two engines: the built-in ocrs (moved out of lib.rs)
  and the installed tesseract program, run as a fenced external process: fixed
  argv, allow-listed language codes, a private temp directory (mode 0700 on
  Unix), 120 s timeout, 64 MiB output cap, 50 000-word reading cap. A location
  from PDFCRAFT_TESSERACT is validated (absolute, existing, executable) and an
  invalid one fails closed instead of falling back to the PATH. `--list-langs`
  and `--version` are read from stdout or, for older versions, stderr.
- Strategies: PrimaryOnly, ConfidenceWeighted (the second engine joins when the
  first reading's mean is under 70 or unknown) and a ROVER merge (higher
  confidence wins per word; against an ocrs word of unknown confidence a
  tesseract word wins only at 70 or more; capped at 100 M comparisons; a failing
  partner degrades to the primary with a note).
- Preprocessing: opt-in auto-rotate, deskew, denoise and binarize on the RGBA
  raster, each with an exact inverse so word boxes map back to the scan. The
  300 dpi normalization the steps are calibrated for runs only when a step is
  on: with every step off the render is read as rendered, as before. (Enlarging
  a 72 dpi render 4x on its own made the models read "due: €250" as "ue: ?250".)
  `OcrImage` is validated by construction (side and 64 MP area caps, checked
  before any buffer is allocated, including by the resize).
- Confidence bands (High >= 90, Medium 70-89, Low < 70) drive review.

pdfcraft-engine, pdfcraft-create
- `OcrSettings` gains engine, strategy, the four steps, output mode, region, the
  skip flags and the low-confidence threshold; Automatic reads confidence
  weighted unless a strategy is chosen.
- A recognition is a session: the document guard refuses a stale result with its
  reason, pages can be applied one at a time (the guard is taken again after each
  applied page), and applying is one undoable step. A per-language dictionary
  (a plain-text file: words added from any document stay on disk after it closes).
- Output is searchable (an invisible layer) or a NEW editable document of visible
  text at the recognized boxes (`create::from_contents`); words beyond WinAnsi use
  the Type3 faces of M10.20 in both, with the shared plan across pages.
- Engine discovery never blocks the interface: results are cached, a stale
  answer is shown while a background probe retries, and the models search and
  `--version` are cached. Batch: pages or files that already have text are
  skipped unless forced, and inputs sharing a file name are found up front.

pdfcraft-automation
- ocr_recognize (engine, strategy, steps, region, output mode), ocr_words (with a
  retained review handle), ocr_review_apply, ocr_dictionary, ocr_status and
  ocr_recognize_files. The editable `out` must be a new file inside --root and is
  refused for a searchable run (it used to be ignored), two batch inputs of one
  file name are refused before anything is read, and the batch folder is checked
  for existing outputs.

pdfcraft-ui-egui
- Only what the new API needs: the Recognize Text dialog reads with
  `recognizers(&settings)`. `ocr.correct` is registered with its label in every
  catalog; the screen itself is M10.22.

Tests: pdfcraft-ocr 78 passed; pdfcraft-create passed; pdfcraft-engine and
pdfcraft-automation 450 passed and 1 failed (the automation lib 17, automation.rs
157, hostile_args 2 among them); pdfcraft-ui-egui lib + ocr + control tests 391
passed; clippy -D warnings clean on the five crates. The one failure is in a
test this commit does not change:
recognize_text_makes_a_scanned_page_searchable fails where the models are
installed (they read "quick" as "auick" at that render size); the roadmap on main
already records it. The real tesseract binary ran in
tesseract::tests::recognize_runs_the_installed_binary (tesseract 5.4.0, PATH).

* M10.22: OCR Verify screen, the Recognize Text options and the interface catalogs (ui-egui)

Scan & OCR ▸ Correct recognized text: a full-window screen where a recognition
is reviewed before anything is applied, plus the dialog and batch options for
the engines of M10.21.

OCR Verify screen
- Four panes: the page list, the source page with confidence-coloured word boxes
  (click to select, drag to draw a region), the recognized text, and a zoom plus
  word inspector. Corrections commit on Enter (an empty commit removes the word),
  Remove/Restore, "+ Dictionary" (a per-language file in the preferences folder,
  so a word added from a document stays on disk after it closes), per-word and
  per-page Verified, F8 / Shift+F8 walk the low-confidence words, Ctrl+T marks the
  page reviewed, Ctrl+Tab moves the pane focus, and review edits undo within the
  panel.
- Re-OCR of the whole page or a drawn region. A region re-read moves the review
  marks of the words it keeps to their new positions and selects the first new
  word; a whole-page re-read clears that page's marks (and the undo brings them
  back). The region outline is scaled onto the pane, not mapped through user
  space again, so it is right on rotated and cropped pages.
- Accept / Reject per page or whole. Pages accepted one at a time each apply once
  (the document guard is taken again after each applied page; an edit made
  elsewhere in between is still refused with its reason), and Accept in
  searchable mode is one undoable step. Editable mode writes a NEW
  `<name>_ocr.pdf` through a save dialog and never the source. A real Cancel
  keeps the pages already read.
- The engine and strategy menus disable a choice that cannot run and say why;
  the check never waits on a process (a background probe fills a cache), and
  choosing Automatic moves "Primary only" to Confidence weighted.

Recognize Text dialog and batch
- Engine, strategy, the four clean-up steps and the skip flags are remembered with
  the other choices (a saved Automatic without a strategy reads confidence
  weighted). The batch refuses two inputs of one file name and existing outputs
  before reading anything, notes low-confidence pages at the settings' threshold,
  and its toast has a Cancel (the file in flight finishes).

Catalogs and attribution
- The 90 new interface strings are in all 21 catalogs (the Lithuanian ones were
  written without native review, and the Bulgarian ones avoid U+045D, which the
  bundled monospace face lacks). ATTRIBUTION.toml carries the new hashes, a
  note on the Lithuanian entries and the README screenshot; ATTRIBUTION.md is
  regenerated.
- README: a screenshot of the Verify screen, taken with
  `cargo xtask screenshots` from a synthetic scan (`shot --ocr-verify on`) built
  from allowed assets; the page reads it with the real engines.

Tests: pdfcraft-ui-egui lib 373 passed (1 ignored: the Windows hinting
measurement probe), tests ocr 6, control 46, fonts 9; clippy -D warnings clean on
the engine, ui-egui, pdfcraft and xtask packages; `cargo xtask assets` ok. The
review state machine has unit tests for every fix above; each was seen failing
with its fix reverted.

* M10.23: parity entries, roadmap and localization notes for the OCR workflow

Records what M10.20-M10.22 ship, and what they do not.

parity/acrobat-features.toml
- Status changes against main: ocr.rotation-detection and ocr.deskew planned ->
  shipped (the opt-in steps of M10.21, with their tests); ocr.correct-recognized-
  text and ocr.ocr-engines planned -> partial. Other Area I entries get their
  evidence and notes brought up to date.
- ocr.correct-recognized-text: the evidence includes the one-page-at-a-time accept
  and the region re-read tests; the notes say what the headless tools cover
  (ocr_words with a retained review, ocr_review_apply through the same stale-
  document guard, ocr_dictionary) and what is still UI-only (suggestions,
  per-word Verified marks, region re-reads).
- ocr.ocr-engines stays partial rather than shipped: part of its evidence is
  conditional (the real-binary test runs only where tesseract is installed and
  skips elsewhere, and the process-level tests are Unix-only). It cites the tests
  that always run (location validation and the fail-closed rule, --list-langs
  parsing from stdout or stderr, the merge rules, the default strategy) and says
  which part is not proven everywhere.
- `cargo xtask parity`: area I 32.7% -> 44.2% weighted (5 -> 7 shipped, 7 -> 9
  partial); overall 52.8% -> 53.0% shipped and 58.2% -> 58.5% weighted.

ROADMAP.md
- One progress-log entry (2026-10-13, newest first) and the banner line. It says
  the catalogs are 21 with the Lithuanian additions not natively reviewed, that
  the end-to-end redaction test of the Type3 layer needs the craft-fonts build
  input, and that recognize_text_makes_a_scanned_page_searchable still fails where
  the models are installed, as main's log already records.

docs/localization.md, docs/localization-parity.md
- The Lithuanian entry count (2,578 -> 2,668) and the counts of the other
  catalogs that carry entry counts are re-measured with the 90 new entries; the
  Lithuanian OCR entries are marked as not natively reviewed.

Checked: `cargo xtask parity` (numbers above, evidence tests exist) and
`cargo xtask assets` pass on this tree.

* M10.24: ocr: apply a late result only to the document it was read from

The Recognize Text dialog closes on Go, so the document can be edited, or have
text redacted, while a long run is still reading. The result was applied
without the stale check the tools and the Verify screen use, so words read
before a redaction could be written back as invisible text. The run now keeps
its guard and applies through the checked path.

Accept with the editable output in the Verify screen had the same gap (Exit
keeps the review open): it now refuses when the document changed since the
review started.

Both new tests fail with their guard switched off.

* M10.25: ocr: ocr_dictionary takes dictionary file names only

Updating sorts, lower-cases and rewrites the file it is given, so pointed at
.env or a README it would corrupt it. The tool now accepts only
ocr-dictionary-<language>.txt style names, for reading as well as writing.

The size-limit test used a file name the rule now refuses first, so it renamed
its fixture to keep testing the size limit. The new test fails with the rule
switched off.

* M10.26: pt-BR: keep (s) on the word counts and say mecanismo for engine

Two review fixes from a native speaker: the two word(s) lines had lost their
(s), so a count of 1 read as 1 palavras, and engine was translated both as
motor and mecanismo. Mecanismo is the Brazilian word for a software engine and
two lines already used it, so the other six now match.

* M10: fit the rebased OCR workflow to current main's structure

* Fix OCR workflow issues from the review

Honor the Recognize dialog's editable-output choice: its result opens in
the review screen (whose Accept writes the editable document) instead of
an invisible layer. Make page acceptance idempotent: a second Accept
entire page never appends the OCR layer twice and keeps later
corrections. Move page and region re-OCR onto a worker thread with a
status line and a cancel that discards the reading. Skip the Tesseract
probe for jobs that name their engines. Bound Tesseract's stderr while
the child runs, not only after it exits. Surface the editable output's
substitution notes in the tools' and the run's results.

Also repair the OCR batch tool against the current structure: inputs are
held to the read root again, and a batch whose inputs share an output
name is refused before the output folder is created, compared the way
the default filesystem compares names; and the editable writer test's
word stays inside the fixture's pages.

* Apply pre-review fixes to the OCR workflow

Hold the automation OCR tools to the document's copying permission, as
the other text tools are: the recognized text is left out of the
results when copying is forbidden, and the paths that carry the words
out of the document (the editable output, the review word list) are
refused. Mark the ocr_dictionary tool as overwriting its path. Close
the searchable layer's graphics state around its invisible text so the
rendering mode cannot leak into later content. Read the OCR user
dictionary folder from the portable data folder in portable mode, like
the recovery store does. Resolve the attribution manifest's leftover
conflict markers and regenerate the generated attribution. Describe the
review flow, the searchable layer's Type3 fonts and the parity evidence
references accurately, and drop an undeclared copy of the recovery
module.

* OCR: reject readings that exceed the word limit

Assisted-by: OpenAI Codex

* OCR: new documents go through the page tree builder main added

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

* OCR: review notes and lints

The editable output refuses a file created during recognition. ocr_words takes
skip_text_pages and force_ocr. Automatic OCR checks the engine it will use. The
stderr test stays below the kill cap, and a failed --ocr-verify shot exits with
an error.

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

* OCR: restore the Verify screenshot attribution, follow the moved constants

The merge dropped the screenshot row with the translation rows. The model
filename constants live in ocrs.rs now, so the license check reads them there.

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

* Attribution: the Verify screenshot entry goes after the header comment

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

* Attribution: the screenshot row in the table follows the manifest order

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

---------

Co-authored-by: eliets2 <287273517+eliets2@users.noreply.github.com>
Co-authored-by: FredApps <FredApps@users.noreply.github.com>
Co-authored-by: FredApps <153173854+FredApps@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-11-05 23:39:55 +01:00
1 parent 250c2623d1
commit 9f04acccef
62 files changed
+11267 -463

No files matched your search

+2 -1
View File
@@ -4,10 +4,11 @@
Every asset PdfCraft includes, bundles or uses to build its published material, with its author, source and licence. The policy is in [AGENTS.md](AGENTS.md) §1. The machine-readable list, with the SHA-256 of every asset, is [ATTRIBUTION.toml](ATTRIBUTION.toml). Licence texts are kept beside the assets and summarised in [NOTICE](NOTICE).
## In this repository (257)
## In this repository (258)
| Asset | Title | Author | Licence | Source | Used for |
|---|---|---|---|---|---|
| `docs/images/pdfcraft-ocr-verify.png` | PdfCraft screenshot: OCR Verify screen | PdfCraft contributors (generated by `cargo xtask screenshots`) | MIT OR Apache-2.0 | this repository | README: OCR review. Shows only PdfCraft UI, its Lucide icons and a synthetic scanned page built from the embedded fonts, with a real recognition (the OCR models) loaded into the Verify screen |
| `assets/icons/accessibility.svg` | Lucide icon "accessibility" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/accessibility.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-left.svg` | Lucide icon "align-left" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-left.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
| `assets/icons/align-center.svg` | Lucide icon "align-center" | Lucide Contributors (portions Cole Bemis, Feather) | ISC AND MIT | https://github.com/lucide-icons/lucide (npm lucide-static 1.49.0, icons/align-center.svg) | UI icon, embedded via crates/ui-egui/src/icon_data.rs |
+11
View File
@@ -10,6 +10,17 @@
# licence: SPDX expression from the allowlist in AGENTS.md §1.2. adobe_data = true marks the closed list of
# Adobe-authored non-visual technical data allowed by AGENTS.md §1.1.
[[asset]]
path = "docs/images/pdfcraft-ocr-verify.png"
title = "PdfCraft screenshot: OCR Verify screen"
author = "PdfCraft contributors (generated by `cargo xtask screenshots`)"
source = "this repository"
licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "image"
usage = "README: OCR review. Shows only PdfCraft UI, its Lucide icons and a synthetic scanned page built from the embedded fonts, with a real recognition (the OCR models) loaded into the Verify screen"
sha256 = "ea7485b151e27ad3fd2a2dac079436ab11745d42032201a029bb20f819c6af8f"
[[asset]]
path = "assets/icons/accessibility.svg"
title = "Lucide icon \"accessibility\""
Generated
+2
View File
@@ -4518,6 +4518,7 @@ version = "0.18.1"
dependencies = [
"ocrs",
"pdfcraft-fonts",
"png 0.18.1",
"rten",
"tempfile",
"thiserror 2.0.21",
@@ -4713,6 +4714,7 @@ dependencies = [
"pdfcraft-engine",
"pdfcraft-fonts",
"pdfcraft-model",
"pdfcraft-ocr",
"pdfcraft-platform",
"pdfcraft-render",
"pdfcraft-xfa",
+12
View File
@@ -253,6 +253,18 @@ Documents restricted by their author show a clear notice, and PdfCraft honours t
</tr>
</table>
## Scan pages, then read the words back before trusting them
**Scan & OCR ▸ Recognize text** turns a scan into searchable text — the current page, a range, or a folder of files written as searchable copies — with optional cleanup before reading (auto-rotate, deskew, denoise, binarize; word boxes map back to the original scan) and a second engine: an installed Tesseract joins the built-in one, merged per your strategy. A searchable result applies as one undoable step; the **Editable text** output opens the **OCR Verify screen** instead, where each word carries the engine's confidence as a colour, suspects are one keypress away, and corrections, a user dictionary and Re-OCR of a region or a page happen before Accept writes the new document. **Scan & OCR ▸ Correct recognized text** reviews any recognition there before it is applied.
<p align="center">
<img src="docs/images/pdfcraft-ocr-verify.png" alt="The OCR Verify screen: a scanned note with each recognized word boxed in its confidence colour, the info strip reporting a 95% average confidence and one low-confidence word, and the confidence legend with its thresholds below" width="100%">
<br>
<sub>The OCR Verify screen: words coloured by confidence — the legend spells the thresholds out — reviewed and corrected before anything is applied.</sub>
</p>
---
## Every tool, one keystroke away
Press <kbd>⌘K</kbd> to search every tool and command, or browse the **All tools** catalogue. Tools that are still in development are marked with the milestone that will ship them.
+1
View File
@@ -329,6 +329,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
- **2026-10-20 (M11, #3507):** Image merging keeps trailing comments, split operands and graphics state across content streams. Synthetic tool tests check saved text, rendered regions and exact image rows.
- **2026-10-17 (M8, gap 6, #2662):** A filled cover over a long line counts when it hides half of any one word, not only 40% of the line's words or box, so a black box over one number in a long line — or an unapplied redaction mark over it — no longer audits clean while the number is still extractable; a sliver under half of every word it touches and under 40% of the line is still not flagged
- **2026-10-13 (M10, OCR review, batch and two engines):** Scan & OCR gains a review step, a second engine and Unicode output. Recognition can clean the raster first (opt-in auto-rotate, deskew, denoise, binarize; word boxes map back to the scan) and read with the built-in ocrs engine, an installed tesseract program run as a fenced external process (a validated location that fails closed instead of falling back to the PATH, allow-listed languages, a private temp directory, timeout and output caps), or both: Automatic asks tesseract when the ocrs reading is weak or of unknown confidence, and a ROVER merge keeps the more confident word (an unscored ocrs word yields only to a tesseract word of 70 or more). The OCR Verify screen (Scan & OCR ▸ Correct recognized text) reviews a recognition before it is applied: confidence-coloured word boxes, corrections, a per-language user dictionary (a plain-text file in the preferences folder, so a word added from any document stays on disk after it closes), Re-OCR of a page or a region, Accept/Reject per page or whole. Pages accepted one by one each apply once; a region re-read moves the review marks of the words it keeps, and a whole-page re-read clears that page's marks (undoable in the panel). Output is searchable (an invisible layer, one undo step) or a NEW editable document of visible text. Words beyond WinAnsi are drawn in Type3 faces from craft-fonts with a ToUnicode map in the editable output (characters no bundled face has appear as '?'); the searchable layer writes them as Type3 fonts with empty character programs and a ToUnicode map, so extraction reads them back with no fonts and no substitutions; redaction removes that layer like any other text (tests in pdfcraft-redact; the end-to-end engine test needs the craft-fonts build input and is skipped without it). Batch runs skip pages that already have text unless forced, and skip whole files that have text only with the opt-in skip-files flag (off by default; Force OCR overrides both); they refuse two inputs of the same file name and existing outputs before reading anything, and report low-confidence pages at the settings' threshold. Headless: ocr_recognize (engine, strategy, preprocessing, region; the editable output goes to an `out` that must not exist yet, and `out` is refused for a searchable run), ocr_words, ocr_review_apply, ocr_dictionary, ocr_status and ocr_recognize_files. All 21 interface catalogs carry the new strings; the Lithuanian ones were added without native review. Reading with every cleaning step off passes the render through untouched, as on main (the 300 dpi normalization runs only when a cleaning step is on: enlarging a 72 dpi render 4x on its own made the models read "due: €250" as "ue: ?250"). Still missing: the deep scan clean-up options (descreen, background and edge-shadow removal), scan images in the editable output, matching the scanned typeface, and recognition quality beyond the tested English set.
- **2026-10-13 (M10, #1757):** Office export keeps each dotted-leader contents entry in one paragraph, with its page number, instead of inferring table cells from its separate text objects.
- **2026-10-22 (M6, calculation scripts):** The Calculate tab and a rename read simplified field notation only from a comment, where Acrobat writes it: a string that spells `BVCALC … EVCALC` is no longer taken for the notation, or rewritten by a rename. A `/` after the condition of `if`, `while`, `for` or `with` starts a regular expression, so a rename no longer rewrites a name inside one, and a quote inside one no longer hides the `getField` calls after it. Both found in review of #3550. A `//` comment ends at `\r`, U+2028 or U+2029 as well as `\n`, so in a script with `\r` line endings the code after one is no longer hidden from a rename, or taken for the notation. Estimates unchanged.
- **2026-10-20 (M6, calculation order page):** Prepare a form ▸ Calculation order, a page of its own, lists the calculated fields in the order they calculate with their formulas and values, moved with Up/Down arrows (as Acrobat's Set Field Calculation Order), Alt+↑/↓ or by dragging. A card says what is wrong with the order and holds Fix order; a loop has a card of its own that opens one of its formulas. On the page, each calculated field shows its step, and arrows show what the field in focus reads and what reads it. Estimates unchanged.
+2
View File
@@ -439,6 +439,8 @@ fn app_creator<'a>(
if let Some(dir) = pdfcraft_ui_egui::RecoveryStore::default_dir() {
app.enable_recovery(pdfcraft_ui_egui::RecoveryStore::new(dir));
}
// The OCR Verify screen stores its per-language dictionary beside recovery data.
app.ocr_verify.dictionary_dir = pdfcraft_ui_egui::default_dictionary_dir();
if let Some(state) = &cc.wgpu_render_state {
let adapter = state.adapter.get_info();
let max_texture_dimension_2d = state.device.limits().max_texture_dimension_2d;
+23
View File
@@ -281,3 +281,26 @@ navigation-history tool; use the opt-in UI control channel to navigate the displ
(`#RRGGBB`, default black) for `text` and `date`. The colour is saved in the
FreeText appearance and survives reopening. Other mark/signature types reject
`color`; their appearance controls are separate.
## Reviewing OCR headlessly
`ocr_words {doc, retain_review: true}` reads words without editing the document and returns
`review`, plus pages and word boxes/confidences. Page and word positions are 1-based.
`region` can restrict recognition to one named page, using displayed-page points. Without
`retain_review`, word inspection remains stateless.
Apply reviewed text with `ocr_review_apply {doc, review, corrections: [{page: 1, word: 1,
text: "Corrected"}]}`. Omitted words keep their text; empty text removes a word. Geometry
comes from the retained recognition, never caller input. The captured document guard rejects
stale/foreign results. Successful application consumes the handle and is one undoable step.
Use `output_mode: "editable", out: "reviewed.pdf"` to write a separate editable document,
or `discard: true` to release the result without applying it. Failed validation preserves the
review for correction or discard. A new retained recognition replaces that document's prior
handle; closing the document releases it. At most eight documents may retain reviews, with
65,536-word/4-MiB input bounds and a 65,536-page cap.
`ocr_dictionary {path: "ocr-dictionary-en.txt", add: ["Quartz"], remove: ["old"]}` atomically
updates the same one-word-per-UTF-8-line format OCR Verify uses. Omit add/remove to read it.
The explicit path honors `--root`, including symlink confinement; choose the appropriate
per-language file used by the UI. Entries are case-insensitive and duplicate-free. Invalid
or oversized inputs fail before writing (65,536 words, 4 MiB, at most 1,024 bytes per word).
+472 -36
View File
@@ -1,98 +1,534 @@
use super::{Args, Automation, Result, ToolError, Value, child, failed, json, write_atomic};
impl Automation {
/// Recognition settings from the arguments: `dpi` (clamped to 72–600), `language` (refused
/// unless the models read it) and `skip_text_pages`, each left at its default when absent.
/// The settings every OCR tool shares: dpi, language, the engine/strategy choice, the four
/// preprocessing flags, the skip flags and the output mode — each named exactly as its tool
/// schema says. An unknown engine, strategy or mode is an honest error, never a silent
/// fallback: a tool run must not quietly do something other than what it was asked.
pub(crate) fn ocr_settings(&self, a: &Args) -> Result<pdfcraft_engine::ocr::OcrSettings> {
use pdfcraft_engine::ocr::{EngineChoice, MergeStrategy, OutputMode};
let mut settings = pdfcraft_engine::ocr::OcrSettings::default();
if let Some(d) = a.opt_num("dpi")? {
settings.dpi = d.clamp(72.0, 600.0) as f32;
}
if let Some(l) = a.opt_str("language")? {
if !pdfcraft_engine::ocr::LANGUAGES.iter().any(|x| x.0 == l) {
return Err(ToolError::InvalidArgs(format!("unsupported language {l:?}")));
return Err(ToolError::InvalidArgs(format!(
"unsupported language {l:?} (supported: {})",
pdfcraft_engine::ocr::LANGUAGES.iter().map(|(c, _)| *c).collect::<Vec<_>>().join(", ")
)));
}
settings.language = l.into();
}
if let Some(s) = a.opt_bool("skip_text_pages")? {
settings.skip_text_pages = s;
if let Some(e) = a.opt_str("engine")? {
settings.engine = match e.trim().to_ascii_lowercase().as_str() {
"ocrs" => EngineChoice::Ocrs,
"tesseract" => EngineChoice::Tesseract,
"auto" | "automatic" | "ensemble" => EngineChoice::Auto,
other => return Err(ToolError::InvalidArgs(format!("unsupported engine {other:?} (ocrs, tesseract or auto)"))),
};
}
settings.strategy = match a.opt_str("strategy")? {
Some(s) => match s.trim().to_ascii_lowercase().as_str() {
"primary" | "primary-only" | "primary_only" => MergeStrategy::PrimaryOnly,
"confidence" | "confidence-weighted" | "confidence_weighted" => MergeStrategy::ConfidenceWeighted,
"rover" | "rover-vote" | "rover_vote" | "vote" => MergeStrategy::RoverVote,
other => return Err(ToolError::InvalidArgs(format!("unsupported strategy {other:?} (primary, confidence or rover)"))),
},
// Unset: the engine choice decides (auto brings in the second engine when the
// first reading is weak; a named engine reads alone).
None => settings.engine.default_strategy(),
};
if let Some(m) = a.opt_str("output_mode")? {
settings.output_mode = match m.trim().to_ascii_lowercase().as_str() {
"searchable" | "searchable-image" | "searchable_image" => OutputMode::Searchable,
"editable" | "editable-text" | "editable_text" => OutputMode::EditableText,
other => return Err(ToolError::InvalidArgs(format!("unsupported output mode {other:?} (searchable or editable)"))),
};
}
for (key, flag) in [
("auto_rotate", &mut settings.auto_rotate),
("deskew", &mut settings.deskew),
("denoise", &mut settings.denoise),
("binarize", &mut settings.binarize),
] {
if let Some(on) = a.opt_bool(key)? {
*flag = on;
}
}
for (key, flag) in [
("skip_text_pages", &mut settings.skip_text_pages),
("skip_text_files", &mut settings.skip_text_files),
("force_ocr", &mut settings.force_ocr),
] {
if let Some(on) = a.opt_bool(key)? {
*flag = on;
}
}
Ok(settings)
}
/// Recognizes text in each of `paths` and writes the result under the same file name in
/// `folder`, reporting per file its output, word count and skipped pages, or its error. A
/// path outside the root refuses the whole call before any work starts.
/// The optional `region` argument ([x0, y0, x1, y1] in points from the top-left of the
/// displayed page) as the job's pixel crop, at the resolution that page is really read at.
/// A region reads exactly one page.
fn ocr_region(
&self,
a: &Args,
id: pdfcraft_engine::DocId,
page: usize,
settings: &pdfcraft_engine::ocr::OcrSettings,
) -> Result<Option<[f32; 4]>> {
const BAD: &str = "region must be [x0, y0, x1, y1] in points";
let Some(v) = a.get("region") else { return Ok(None) };
let arr = v.as_array().ok_or_else(|| ToolError::InvalidArgs(BAD.into()))?;
if arr.len() != 4 {
return Err(ToolError::InvalidArgs(BAD.into()));
}
let mut pts = [0.0f64; 4];
for (i, x) in arr.iter().enumerate() {
pts[i] = x.as_f64().filter(|f| f.is_finite()).ok_or_else(|| ToolError::InvalidArgs(BAD.into()))?;
}
let scale = f64::from(
self.session.ocr_job(id, &[page], settings.clone()).ok_or_else(|| failed("the document could not be read"))?.render_scale(page),
);
let px: Vec<f32> = pts.iter().map(|p| scale * p).map(|p| if p.is_finite() { p } else { 0.0 }).map(|p| p as f32).collect();
Ok(Some([px[0], px[1], px[2], px[3]]))
}
/// The per-page result entries the OCR tools return: recognised text or why the page was
/// passed over. The text itself is only echoed when the document allows copying; the
/// counts and metadata always are.
fn ocr_pages_json(found: &[pdfcraft_engine::ocr::OcrPage], include_text: bool) -> Vec<Value> {
found
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None => {
let mut page = json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page });
// The editable output's substitution warnings (characters no font could
// show) are part of the result: reported per page, never dropped.
if !p.notes.is_empty() {
page["notes"] = json!(p.notes);
}
if include_text {
page["text"] = json!(p.text());
}
page
}
})
.collect()
}
pub(crate) fn ocr_recognize_files(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
let folder = self.resolve(a.str("folder")?, true)?;
// A path that leaves the root refuses the whole call before any work starts, like the
// other tools (the write check passes missing files, which stay per-file errors below).
// A path that leaves the read root refuses the whole call before any work starts;
// a file that is merely missing stays a per-file error below.
let paths = a.strs("paths")?;
for p in &paths {
self.resolve_for(p, crate::file_access::Access::Read, true)?;
}
// Two inputs that would write the same output file overwrite each other while both report
// success, so the whole batch is refused here: before the folder is created, anything is
// read, or the OCR engines load.
// Two inputs of one file name (from different folders) would write the same output:
// the second would replace the first while both report success. The whole batch is
// refused up front — before the folder is created, anything is read or the engines
// load — naming the file, the way the default filesystem compares names.
let targets: Vec<std::path::PathBuf> = paths.iter().map(|p| child(&folder, &input_file_name(p))).collect();
let shared = shared_file_names(&targets);
if !shared.is_empty() {
return Err(ToolError::InvalidArgs(format!(
"more than one input would write to the same output file, so each would replace the one before it. Colliding file names: {}. Give each input a different file name, or run them in separate batches with different folders.",
"several paths share a file name, so their outputs in folder would overwrite each other (rename or run them separately): {}",
shared.join(", ")
)));
}
std::fs::create_dir_all(&folder).map_err(|e| failed(e.to_string()))?;
let ocr = pdfcraft_engine::ocr::engine().map_err(failed)?;
let names: Vec<String> = paths.iter().map(|p| input_file_name(p)).collect();
// Collision pre-flight: a searchable copy never silently overwrites. Every target is
// checked before the first page is read; a collision aborts the whole batch with the
// names in the error.
let mut collisions = std::collections::BTreeSet::new();
for name in names {
if child(&folder, &name).exists() {
collisions.insert(name);
}
}
if !collisions.is_empty() {
let mut list: Vec<String> = collisions.iter().take(5).cloned().collect();
if collisions.len() > list.len() {
list.push(format!("and {} more", collisions.len() - list.len()));
}
return Err(failed(format!(
"the output folder already holds {} (delete or move them, or choose another folder): {}",
if collisions.len() == 1 { "a file of that name" } else { "files of those names" },
list.join(", ")
)));
}
// The engines are built (and named if they cannot be) before the first file is read.
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
let mut out = Vec::new();
let (mut ok, mut skipped_files, mut words) = (0usize, 0usize, 0usize);
for p in paths {
let name = input_file_name(p);
let result =
self.resolve(p, false).map_err(|e| e.to_string()).and_then(|src| crate::read_input_bytes(&src).map_err(|e| e.to_string())).and_then(
|bytes| pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true),
|bytes| {
pdfcraft_engine::ocr::recognize_file(&name, std::sync::Arc::new(bytes), None, settings.clone(), &recognizers, |_, _| true)
},
);
out.push(match result {
match result {
Ok(r) => {
let skipped_pages: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
if r.skipped() {
// A file passed over by the skip-files option is its own bucket: its
// original is left alone and nothing is written.
skipped_files += 1;
out.push(json!({ "path": p, "skipped": r.file_skipped, "skipped_pages": skipped_pages }));
continue;
}
let target = child(&folder, &name);
write_atomic(&target, &r.bytes)?;
let skipped: Vec<usize> = r.pages.iter().filter(|p| p.skipped.is_some()).map(|p| p.page + 1).collect();
json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped })
ok += 1;
words += r.words();
let mut entry = json!({ "path": p, "output": target.to_string_lossy(), "words": r.words(), "skipped_pages": skipped_pages });
// The batch's low-confidence note: at or under the settings' threshold (60
// by default), on 1-based sorted pages.
let threshold = settings.low_confidence_threshold;
if let Some((count, pages)) = pdfcraft_engine::ocr::low_confidence_pages(&r.pages, threshold) {
entry["low_confidence"] = json!({ "count": count, "pages": pages, "threshold": threshold });
}
out.push(entry);
}
Err(e) => json!({ "path": p, "error": e }),
});
Err(e) => out.push(json!({ "path": p, "error": e })),
}
}
Ok(json!({ "files": out }))
Ok(json!({ "files": out, "recognized": ok, "words": words, "skipped_files": skipped_files }))
}
/// Recognizes text on the open document's `pages` (every page when absent) and adds it,
/// reporting the total word count and, per page, why it was skipped or what it found.
pub(crate) fn ocr_recognize(&mut self, a: &Args) -> Result<Value> {
let settings = self.ocr_settings(a)?;
use pdfcraft_engine::ocr::OutputMode;
let mut settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
let doc = self.doc(a)?;
// Recognizing needs only permission to change the document; the words it found are
// returned only when copying is allowed too.
let (id, copy) = (doc.id, doc.allows_copying());
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let (id, copy) = {
let doc = self.doc(a)?;
(doc.id, doc.allows_copying())
};
// The optional region reads exactly one page.
if a.get("region").is_some() {
if pages.len() != 1 {
return Err(ToolError::InvalidArgs("region reads one page: name it in pages".into()));
}
settings.region = self.ocr_region(a, id, pages[0], &settings)?;
}
// The editable output's destination is checked before anything runs: a NEW file, never
// the open source and never an existing file replaced silently.
let out = match settings.output_mode {
OutputMode::Searchable => {
// `out` belongs to the editable output; a searchable run writes into the open
// document, so an `out` here would be silently ignored (and never checked
// against the root).
if a.get("out").is_some() {
return Err(ToolError::InvalidArgs("out is only used with output_mode editable; remove it, or choose editable".into()));
}
None
}
OutputMode::EditableText => {
// The editable output writes the recognized text out of the document: a
// document that forbids copying refuses here, before anything runs.
self.doc_to_copy(a)?;
// A write: the file does not exist yet, and write_atomic makes its folder.
let out = self.resolve(a.str("out")?, true)?;
if let Some(source) = self.session.get(id).and_then(|d| d.path.clone())
&& let (Ok(a), Ok(b)) = (std::fs::canonicalize(&out), std::fs::canonicalize(&source))
&& a == b
{
return Err(failed("out is the source document; the editable output is a new file, so choose another path"));
}
if out.exists() {
return Err(failed(format!(
"out already exists ({}); the editable output is a new file, so choose a path that does not exist yet",
out.to_string_lossy()
)));
}
Some(out)
}
};
// Honest up front: the engines are built (and named if they cannot be) before anything
// is read or written.
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
match out {
None => {
let found = self.session.recognize_text(id, &pages, settings).map_err(failed)?;
let words = found.iter().map(|p| p.words.len()).sum::<usize>();
Ok(json!({ "words": words, "pages": Self::ocr_pages_json(&found, copy) }))
}
Some(out) => {
let job = self.session.ocr_job(id, &pages, settings).ok_or_else(|| failed("the document could not be read"))?;
let found = job.run(&recognizers, |_, _| true);
let bytes = self.session.editable_ocr_bytes(id, &found).map_err(failed)?;
// recognition can take minutes: a file created meanwhile is refused, not replaced
if let Some(folder) = out.parent() {
std::fs::create_dir_all(folder).map_err(failed)?;
}
crate::bates::publish_new(&out, &bytes)?;
Ok(json!({
"out": out.to_string_lossy(),
"words": found.iter().map(|p| p.words.len()).sum::<usize>(),
"pages": Self::ocr_pages_json(&found, copy),
}))
}
}
}
/// The words of a recognition, for reviewing without the GUI: text, box (points from the
/// top-left of the displayed page), confidence, band, and which words are suspects.
/// Changes nothing; needs the engines (ocr_status).
pub(crate) fn ocr_words(&mut self, a: &Args) -> Result<Value> {
let mut settings = self.ocr_settings(a)?;
let pages = if a.opt_ints("pages")?.is_some() { self.pages(a, "pages")? } else { Vec::new() };
// The word list is the document's text, word by word: refused when the document
// forbids copying it out.
let id = self.doc_to_copy(a)?.id;
if a.get("region").is_some() {
if pages.len() != 1 {
return Err(ToolError::InvalidArgs("region reads one page: name it in pages".into()));
}
settings.region = self.ocr_region(a, id, pages[0], &settings)?;
}
let retain = a.opt_bool("retain_review")?.unwrap_or(false);
if retain && !self.ocr_reviews.contains_key(&id) && self.ocr_reviews.len() >= 8 {
return Err(failed("at most 8 OCR reviews may be retained; discard one first"));
}
let recognizers = pdfcraft_engine::ocr::recognizers(&settings).map_err(failed)?;
let job = self.session.ocr_job(id, &pages, settings.clone()).ok_or_else(|| failed("the document could not be read"))?;
let infos: std::collections::HashMap<usize, pdfcraft_render::PageInfo> =
self.session.get(id).map(|d| d.info.pages.clone()).unwrap_or_default().into_iter().enumerate().collect();
let guard = job.guard.clone();
let found = job.run(&recognizers, |_, _| true);
if found.len() > 65536 {
return Err(failed("at most 65536 OCR review pages are allowed"));
}
pdfcraft_engine::ocr::validate_ocr_words(found.iter().flat_map(|p| &p.words)).map_err(failed)?;
let band_name = |band: pdfcraft_engine::ocr::ConfidenceBand| match band {
pdfcraft_engine::ocr::ConfidenceBand::High => "high",
pdfcraft_engine::ocr::ConfidenceBand::Medium => "medium",
pdfcraft_engine::ocr::ConfidenceBand::Low => "low",
};
let mut suspects = 0usize;
let list: Vec<Value> = found
.iter()
.map(|p| match &p.skipped {
Some(why) => json!({ "page": p.page + 1, "skipped": why }),
None if copy => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page, "text": p.text() }),
None => json!({ "page": p.page + 1, "words": p.words.len(), "already_on_page": p.already_on_page }),
.map(|p| {
let info = infos.get(&p.page);
let words: Vec<Value> = p
.words
.iter()
.map(|w| {
let suspect = pdfcraft_engine::ocr::is_suspect(w.confidence);
if suspect {
suspects += 1;
}
// The word's corners in view space, as the box [x, y, w, h] in points
// from the top-left of the displayed page.
let view = |pt: [f64; 2]| info.map(|i| i.user_to_view(pt[0] as f32, pt[1] as f32));
let corners = [
view(w.origin),
view([w.origin[0] + w.across[0], w.origin[1] + w.across[1]]),
view([w.origin[0] + w.up[0], w.origin[1] + w.up[1]]),
view([w.origin[0] + w.across[0] + w.up[0], w.origin[1] + w.across[1] + w.up[1]]),
];
let xs: Vec<f32> = corners.iter().flatten().map(|v| v[0]).collect();
let ys: Vec<f32> = corners.iter().flatten().map(|v| v[1]).collect();
let (min_x, min_y) = (xs.iter().cloned().fold(f32::INFINITY, f32::min), ys.iter().cloned().fold(f32::INFINITY, f32::min));
let (w_pt, h_pt) = (
xs.iter().cloned().fold(f32::NEG_INFINITY, f32::max) - min_x,
ys.iter().cloned().fold(f32::NEG_INFINITY, f32::max) - min_y,
);
json!({
"text": w.text,
"box": [min_x, min_y, w_pt, h_pt],
"confidence": w.confidence.map(|c| (c * 10.0).round() / 10.0),
"band": w.confidence.map(|c| band_name(pdfcraft_engine::ocr::band_for(c))),
"suspect": suspect,
"source": w.source,
})
})
.collect();
let mut page =
json!({ "page": p.page + 1, "already_on_page": p.already_on_page, "words": words, "mean_confidence": p.mean_confidence().map(|c| (c * 10.0).round() / 10.0) });
if let Some(why) = &p.skipped {
page["skipped"] = json!(why);
}
page
})
.collect();
Ok(json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "pages": list }))
let mut out = json!({ "words": found.iter().map(|p| p.words.len()).sum::<usize>(), "suspects": suspects, "pages": list });
if retain {
self.next_ocr_review =
self.next_ocr_review.checked_add(1).filter(|n| *n <= i64::MAX as u64).ok_or_else(|| failed("OCR review handles exhausted"))?;
out["review"] = json!(self.next_ocr_review);
self.ocr_reviews.insert(id, crate::OcrReview { handle: self.next_ocr_review, guard, pages: found });
}
Ok(out)
}
/// Apply only caller corrections to retained original boxes, through the stored guard.
pub(crate) fn ocr_review_apply(&mut self, a: &Args) -> Result<Value> {
let id = self.doc(a)?.id;
let handle = a.int("review")?;
let review = self
.ocr_reviews
.get(&id)
.filter(|r| i64::try_from(r.handle).ok() == Some(handle))
.ok_or_else(|| failed("unknown OCR review for this document; call ocr_words with retain_review"))?;
if a.opt_bool("discard")?.unwrap_or(false) {
if a.get("corrections").is_some() {
return Err(ToolError::InvalidArgs("discard cannot include corrections".into()));
}
self.ocr_reviews.remove(&id);
return Ok(json!({ "discarded": handle }));
}
if let Some(why) = self.session.stale_reason(&review.guard) {
return Err(failed(why));
}
let corrections = a.get("corrections").map(|v| v.as_array().ok_or_else(|| Args::wrong("corrections", "an array"))).transpose()?;
if corrections.is_some_and(|c| c.len() > 65536) {
return Err(failed("at most 65536 OCR corrections are allowed"));
}
// Prevalidate and cap every replacement before cloning the retained result.
let mut edits = Vec::new();
let mut bytes = 0usize;
let mut seen = std::collections::BTreeSet::new();
let page_indices: std::collections::BTreeMap<_, _> = review.pages.iter().enumerate().map(|(i, p)| (p.page, i)).collect();
for item in corrections.into_iter().flatten() {
if !item.is_object() || item.as_object().is_some_and(|o| o.keys().any(|k| !matches!(k.as_str(), "page" | "word" | "text"))) {
return Err(ToolError::InvalidArgs("corrections accept only page, word and text".into()));
}
let c = Args(item);
let page =
usize::try_from(c.int("page")?).ok().and_then(|p| p.checked_sub(1)).ok_or_else(|| Args::wrong("page", "a 1-based position"))?;
let word =
usize::try_from(c.int("word")?).ok().and_then(|w| w.checked_sub(1)).ok_or_else(|| Args::wrong("word", "a 1-based position"))?;
let text = c.str("text")?;
bytes = bytes.checked_add(text.len()).filter(|n| *n <= 4 * 1024 * 1024).ok_or_else(|| failed("OCR corrections exceed 4 MiB"))?;
if !seen.insert((page, word)) {
return Err(failed("duplicate OCR word correction"));
}
let pi = page_indices.get(&page).copied().ok_or_else(|| failed("page is not in this OCR review"))?;
if review.pages.get(pi).and_then(|p| p.words.get(word)).is_none() {
return Err(failed("word is not in this OCR review"));
}
edits.push((pi, word, text.to_owned()));
}
let mut pages = review.pages.clone();
for (pi, wi, text) in edits {
if let Some(w) = pages.get_mut(pi).and_then(|p| p.words.get_mut(wi)) {
w.text = text;
}
}
pdfcraft_engine::ocr::validate_ocr_words(pages.iter().flat_map(|p| &p.words)).map_err(failed)?;
for page in &mut pages {
page.words.retain(|w| !w.text.trim().is_empty());
}
if pages.iter().all(|p| p.words.is_empty()) {
return Err(failed("the OCR review has no words to apply"));
}
let out = match a.opt_str("output_mode")?.unwrap_or("searchable") {
"searchable" => {
if a.get("out").is_some() {
return Err(ToolError::InvalidArgs("out is only for editable output".into()));
}
let count = self.session.apply_ocr_checked(id, &pages, &review.guard).map_err(failed)?;
json!({ "applied": count, "review": handle })
}
"editable" => {
// The editable output writes the review's words out of the document: a
// document that forbids copying refuses here (the review is kept).
self.doc_to_copy(a)?;
let target = self.resolve(a.str("out")?, true)?;
if self
.session
.get(id)
.and_then(|d| d.path.as_deref())
.and_then(|p| std::fs::canonicalize(p).ok())
.as_ref()
.is_some_and(|source| std::fs::canonicalize(&target).as_ref().ok() == Some(source))
{
return Err(failed("out is the source document; choose a new file"));
}
// A NEW file: an existing one is never replaced silently (the review is kept).
if target.exists() {
return Err(failed(format!("out already exists ({}); choose a path that does not exist yet", target.to_string_lossy())));
}
let bytes = self.session.editable_ocr_bytes(id, &pages).map_err(failed)?;
write_atomic(&target, &bytes)?;
json!({ "out": target.to_string_lossy(), "review": handle, "words": pages.iter().map(|p| p.words.len()).sum::<usize>() })
}
_ => return Err(ToolError::InvalidArgs("output_mode must be searchable or editable".into())),
};
self.ocr_reviews.remove(&id);
Ok(out)
}
/// Read/update the same per-language dictionary file used by OCR Verify.
pub(crate) fn ocr_dictionary(&mut self, a: &Args) -> Result<Value> {
use pdfcraft_engine::ocr::dictionary;
use std::io::Read as _;
for key in ["add", "remove"] {
if let Some(value) = a.get(key) {
let values = value.as_array().ok_or_else(|| Args::wrong(key, "an array of strings"))?;
if values.len() > 65536 {
return Err(failed("at most 65536 dictionary words are allowed"));
}
}
}
let add = if a.get("add").is_some() { a.strs("add")? } else { Vec::new() };
let remove = if a.get("remove").is_some() { a.strs("remove")? } else { Vec::new() };
let writing = a.get("add").is_some() || a.get("remove").is_some();
// Updating sorts, lower-cases and rewrites the file, so this tool names dictionary files only:
// pointed at `.env` or a README it would corrupt it.
let file = std::path::Path::new(a.str("path")?).file_name().and_then(|n| n.to_str()).unwrap_or("");
let plain = file.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
if file.len() > 64 || !plain || !file.starts_with("ocr-dictionary") || !file.ends_with(".txt") {
return Err(failed("path must name a dictionary file: ocr-dictionary-<language>.txt (letters, digits, '-', '_' and '.' only)"));
}
let path = self.resolve(a.str("path")?, writing)?;
let mut input = String::new();
match std::fs::File::open(&path) {
Ok(file) => {
file.take((dictionary::MAX_BYTES + 1) as u64).read_to_string(&mut input).map_err(|e| failed(format!("OCR dictionary: {e}")))?;
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound && writing => {}
Err(e) => return Err(failed(format!("OCR dictionary: {e}"))),
}
let text = dictionary::update(&input, &add, &remove).map_err(failed)?;
if writing {
write_atomic(&path, text.as_bytes())?;
}
let words = dictionary::words(&text).map_err(failed)?;
Ok(json!({ "path": path.to_string_lossy(), "count": words.len(), "words": words }))
}
/// Whether text recognition's models are installed, the directories searched for them (in
/// order), and the languages it reads, each as a code and a name.
pub(crate) fn ocr_status(&self) -> Result<Value> {
use pdfcraft_engine::ocr;
let dirs: Vec<String> = ocr::Models::search_dirs().iter().map(|d| d.to_string_lossy().into_owned()).collect();
let langs: Vec<Value> = ocr::LANGUAGES.iter().map(|(c, n)| json!({ "code": c, "name": n })).collect();
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs }))
let engines: Vec<Value> = ocr::engines_status()
.iter()
.map(|e| {
let mut v = json!({ "id": e.id, "available": e.available });
if let Some(p) = &e.program {
v["path"] = json!(p);
}
if let Some(vv) = &e.version {
v["version"] = json!(vv);
}
if let Some(why) = &e.why_not {
v["unavailable"] = json!(why);
}
v
})
.collect();
Ok(json!({ "available": ocr::available(), "search_dirs": dirs, "languages": langs, "engines": engines }))
}
}
+2 -2
View File
@@ -84,8 +84,8 @@ impl<'a> Recipe<'a> {
match *step {
"convert" => bytes = session.convert_to_pdf(name, &bytes)?.1,
"ocr" => {
let engine = ocr::engine().map_err(failed)?;
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &engine, |_, _| true).map_err(failed)?;
let recognizers = ocr::recognizers(&self.ocr).map_err(failed)?;
let result = ocr::recognize_file(name, bytes, None, self.ocr.clone(), &recognizers, |_, _| true).map_err(failed)?;
log.push(
json!({"step":"ocr","words":result.words(),"skipped_pages":result.pages.iter().filter(|p| p.skipped.is_some()).count()}),
);
+1 -1
View File
@@ -199,7 +199,7 @@ impl Automation {
}
/// Publish a PDF atomically while refusing a target created by another process after preflight.
fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
pub(super) fn publish_new(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().ok_or_else(|| failed("output has no parent directory"))?;
let mut temp = tempfile::NamedTempFile::new_in(parent).map_err(failed)?;
temp.write_all(bytes).map_err(failed)?;
+3
View File
@@ -812,6 +812,9 @@ impl Automation {
"form_set_actions" => self.form_set_actions(&a)?,
"ocr_status" => self.ocr_status()?,
"ocr_recognize_files" => self.ocr_recognize_files(&a)?,
"ocr_words" => self.ocr_words(&a)?,
"ocr_review_apply" => self.ocr_review_apply(&a)?,
"ocr_dictionary" => self.ocr_dictionary(&a)?,
"accessibility_report" => self.a11y_report(&a)?,
"accessibility_review_record" => self.a11y_review_record(&a)?,
"accessibility_review_save" => self.a11y_review_save(&a)?,
+1
View File
@@ -120,6 +120,7 @@ impl Automation {
self.texts.remove(&id);
self.listed.retain(|(d, _), _| *d != id);
self.evicted.remove(&id);
self.ocr_reviews.remove(&id);
Ok(json!({ "closed": id.0 }))
}
+11
View File
@@ -76,6 +76,12 @@ pub(crate) use paths::{read_input, read_input_bytes};
#[cfg(test)]
use paths::{write_atomic_synced, write_atomic_with};
struct OcrReview {
handle: u64,
guard: pdfcraft_engine::ocr::OcrGuard,
pages: Vec<pdfcraft_engine::ocr::OcrPage>,
}
/// A headless PdfCraft session driven by tool calls.
pub struct Automation {
session: Session,
@@ -92,6 +98,9 @@ pub struct Automation {
/// Documents that lost a listing to the cap. A page of one with no listing is refused rather
/// than read as "a number is a position", which the page may no longer match.
evicted: HashSet<DocId>,
/// Review sessions held for `ocr_words` (a guard and the pages read), by document.
ocr_reviews: HashMap<DocId, OcrReview>,
next_ocr_review: u64,
}
impl Default for Automation {
@@ -110,6 +119,8 @@ impl Automation {
texts: TextCache::default(),
listed: HashMap::new(),
evicted: HashSet::new(),
ocr_reviews: HashMap::new(),
next_ocr_review: 0,
}
}
+61 -7
View File
@@ -85,6 +85,12 @@ fn schema(props: Value, required: &[&str]) -> Value {
json!({ "type": "object", "properties": props, "required": required, "additionalProperties": false })
}
/// The language codes the engines read, as the `language` enum — from the engine's own table,
/// so the schema and the validator can never disagree.
fn language_enum() -> Value {
Value::Array(pdfcraft_engine::ocr::LANGUAGES.iter().map(|(code, _)| json!(code)).collect())
}
struct T {
name: &'static str,
title: &'static str,
@@ -1373,31 +1379,79 @@ pub fn tools() -> Vec<ToolDef> {
)),
t("js_enabled", "JavaScript on or off", "Preferences ▸ JavaScript ▸ Enable Acrobat JavaScript: set it with `enabled`, or read it. With JavaScript off, field scripts other than Acrobat's AF calls don't run.")
.with(schema(json!({ "enabled": { "type": "boolean" } }), &[])),
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages is false; then pictures on those pages are read too, and recognised words that the page's own text already has (an earlier OCR layer included) are left out and counted as already_on_page. Returns each page's recognised text (left out when the document's security doesn't allow copying), word count or why it was skipped. Needs the OCR models (ocr_status). Undoable as one step.")
t("ocr_recognize", "Recognize text (OCR)", "Scan & OCR ▸ Recognize text: render pages, read the words in them and add them as invisible text over the page image, so scanned pages become searchable and selectable (a searchable image; the image is not changed). Pages that already have text are skipped unless skip_text_pages or force_ocr say otherwise; words overlapping existing text are omitted and counted as already_on_page. With output_mode \"editable\" nothing is applied: a NEW document of visible text at the recognised boxes is written to out (which must not be the source; empty pages stay blank). engine and strategy choose the readers (an engine that cannot run is an error named up front); the four preprocessing flags clean the raster first. region reads just that part of one page ([x0, y0, x1, y1] in points from the top-left of the displayed page). Returns each page's recognised text, word count or why it was skipped. Needs the engines (ocr_status). Searchable output is undoable as one step.")
.cmd("ocr.recognize")
.with(schema(
json!({
"doc": doc(),
"pages": pages("to recognise (default: all)"),
"dpi": { "type": "number", "minimum": 72, "maximum": 600, "description": "Resolution pages are read at (default 300; a scanned page is read at most at its own resolution)." },
"language": { "type": "string", "enum": ["en"], "description": "Document language (default en)." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true). false also reads pictures on such pages, without adding their text again." },
"language": { "type": "string", "enum": language_enum(), "description": "Document language (default en)." },
"engine": { "type": "string", "enum": ["auto", "ocrs", "tesseract"], "description": "Which engine reads the pages (default ocrs). auto: ocrs first and tesseract as the second engine, combined by strategy (only the engines that can run take part)." },
"strategy": { "type": "string", "enum": ["primary", "confidence", "rover"], "description": "How two engines' readings combine: primary = the first engine alone; confidence = the second engine joins when the first reading's mean confidence is under 70 or unknown; rover = both always read, merged. Default: confidence with engine auto, primary otherwise." },
"output_mode": { "type": "string", "enum": ["searchable", "editable"], "description": "searchable (default): invisible text over the pages of the open document. editable: a NEW <document>_ocr-style file of visible text written to out; the source is not touched." },
"out": { "type": "string", "description": "Where the editable output is written (required with output_mode editable; a new file: never the source path, never an existing file)." },
"region": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4, "description": "[x0, y0, x1, y1] in points from the top-left of the displayed page: read only that part. Needs exactly one page." },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." },
"skip_text_files": { "type": "boolean", "description": "Batch: leave whole files that already contain text alone (they come back skipped, not read)." },
"force_ocr": { "type": "boolean", "description": "Read pages and files even when the skip options would leave them alone (default false)." },
"auto_rotate": { "type": "boolean", "description": "Turn pages scanned sideways or upside down upright before reading (default false)." },
"deskew": { "type": "boolean", "description": "Straighten skewed text lines before reading (default false)." },
"denoise": { "type": "boolean", "description": "A 3×3 median filter against scan speckle (default false)." },
"binarize": { "type": "boolean", "description": "Sauvola binarization: ink to black, paper to white (default false)." },
}),
&["doc"],
)),
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name (pages that already have text are left alone unless skip_text_pages is false, as in ocr_recognize). Two inputs that would write the same output file are refused before anything is read or written. Returns, per file, the output path, word count and skipped pages, or the error.")
t("ocr_words", "Words of a recognition (for review)", "The words a recognition finds, for inspection without the GUI: per page, each word's text, its box ([x, y, w, h] in points from the top-left of the displayed page), its confidence and band (high >= 90, medium 70–89, low < 70), which engine read it, and whether it is a suspect (low band). suspects counts the words a reviewer should look at first. Changes nothing on the document. retain_review returns a session-local handle for ocr_review_apply (one per document, at most eight; a new retained recognition replaces the old one). Needs the engines (ocr_status).")
.ro()
.with(schema(
json!({
"doc": doc(),
"retain_review": { "type": "boolean" },
"region": { "type": "array", "items": { "type": "number" }, "minItems": 4, "maxItems": 4 },
"pages": pages("to read (default: all)"),
"dpi": { "type": "number", "minimum": 72, "maximum": 600 },
"language": { "type": "string", "enum": language_enum() },
"engine": { "type": "string", "enum": ["auto", "ocrs", "tesseract"] },
"strategy": { "type": "string", "enum": ["primary", "confidence", "rover"] },
"auto_rotate": { "type": "boolean" },
"deskew": { "type": "boolean" },
"denoise": { "type": "boolean" },
"binarize": { "type": "boolean" },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." },
"force_ocr": { "type": "boolean", "description": "Read pages even when the skip options would leave them alone (default false)." },
}),
&["doc"],
)),
t("ocr_review_apply", "Apply or discard reviewed OCR words", "Accept corrected recognition from ocr_words retain_review. Corrections use 1-based page/word positions and text only; empty text removes a word, omitted words keep their original text and boxes. The stored document guard refuses stale, foreign or consumed reviews. Searchable output is one undoable step; editable requires out and writes a new document (out must not exist yet; the review is kept when it does). discard releases the review without applying it. Input is capped at 65536 words and 4 MiB, never truncated.")
.cmd("ocr.correct")
.with(schema(json!({ "doc": doc(), "review": { "type": "integer", "minimum": 1 }, "discard": { "type": "boolean" }, "output_mode": { "type": "string", "enum": ["searchable", "editable"] }, "out": { "type": "string" },
"corrections": { "type": "array", "maxItems": 65536, "items": { "type": "object", "additionalProperties": false, "properties": { "page": { "type": "integer", "minimum": 1 }, "word": { "type": "integer", "minimum": 1 }, "text": { "type": "string" } }, "required": ["page", "word", "text"] } } }), &["doc", "review"])),
t("ocr_dictionary", "Read or update an OCR review dictionary", "Read or add/remove words in an OCR Verify dictionary file (one case-insensitive word per UTF-8 line). Choose its per-language ocr-dictionary-<language>.txt path. Paths honor root; updates are atomic. Refuses invalid words, over 65536 entries or over 4 MiB. No document changes.")
.destructive()
.cmd("ocr.correct")
.with(schema(json!({ "path": { "type": "string" }, "add": { "type": "array", "maxItems": 65536, "items": { "type": "string" } }, "remove": { "type": "array", "maxItems": 65536, "items": { "type": "string" } } }), &["path"])),
t("ocr_recognize_files", "Recognize text in multiple files", "Scan & OCR ▸ Recognize text ▸ In multiple files: read every page of each PDF in paths and write the searchable result into folder under the same name. skip_text_files leaves whole files that already contain text alone (they are their own skipped bucket, nothing is written); skip_text_pages leaves single text pages alone; force_ocr reads regardless of both. Every output name is checked before the first page is read: two paths sharing a file name (from different folders) are refused, and a file of that name already in folder aborts the batch, with the names in the error. Each written file carries a low_confidence note when words at or under 60 confidence sit on it (1-based sorted pages). Returns, per file, the output path, word count, skipped pages or the error, plus recognized/words/skipped_files totals. Needs the engines (ocr_status).")
.cmd("ocr.recognize_batch")
.with(schema(
json!({
"paths": { "type": "array", "items": { "type": "string" }, "minItems": 1 },
"folder": { "type": "string" },
"dpi": { "type": "number", "minimum": 72, "maximum": 600 },
"language": { "type": "string", "enum": ["en"] },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true); see ocr_recognize." },
"language": { "type": "string", "enum": language_enum() },
"engine": { "type": "string", "enum": ["auto", "ocrs", "tesseract"] },
"strategy": { "type": "string", "enum": ["primary", "confidence", "rover"] },
"skip_text_pages": { "type": "boolean", "description": "Leave pages that already contain text alone (default true)." },
"skip_text_files": { "type": "boolean", "description": "Leave whole files that already contain text alone (default false)." },
"force_ocr": { "type": "boolean", "description": "Read even where the skip options would leave things alone (default false)." },
"auto_rotate": { "type": "boolean" },
"deskew": { "type": "boolean" },
"denoise": { "type": "boolean" },
"binarize": { "type": "boolean" },
}),
&["paths", "folder"],
)),
t("ocr_status", "OCR status", "Whether text recognition is available (its models are installed: release packages include them; a source build fetches them with `cargo xtask models`; PDFCRAFT_MODELS overrides), where it looks for them, and the languages it reads.")
t("ocr_status", "OCR status", "Whether text recognition is available, where it looks for models (release packages include them; a source build fetches them with `cargo xtask models`; PDFCRAFT_MODELS overrides), its languages, and the engines: ocrs (built in, models) and tesseract (an installed program, with its path).")
.ro()
.cmd("ocr.recognize")
.with(schema(json!({}), &[])),
+282 -14
View File
@@ -843,10 +843,11 @@ fn arabic_ligature_actual_text_is_one_editable_word_through_save_reopen() {
}
/// Each of these writes to its required `path` and replaces an existing file there, so the MCP
/// annotations must not tell clients the call is read-only or harmless (#130).
/// annotations must not tell clients the call is read-only or harmless (#130). The OCR
/// dictionary's updates rewrite the file in place (lower-cased and sorted), so it counts too.
#[test]
fn file_writing_tools_are_not_read_only() {
for name in ["doc_export_data", "accessibility_report", "image_save"] {
for name in ["doc_export_data", "accessibility_report", "image_save", "ocr_dictionary"] {
let t = tools().into_iter().find(|t| t.name == name).unwrap();
assert!(!t.read_only, "{name} writes a file but advertises read-only");
assert!(t.destructive, "{name} overwrites its path but advertises non-destructive");
@@ -2580,6 +2581,16 @@ fn copy_restricted_text_stays_in_the_document() {
}
let restyled = ok(&mut a, "text_edit", json!({ "doc": re, "page": 1, "paragraph": 1, "size": 30 }));
assert!(!restyled.to_string().contains("Page"), "{restyled}");
// A path that would carry the page's words OUT of the document — the editable output, or
// the review word list — is refused on a copy-restricted document (no engines needed).
match a.call("ocr_recognize", &json!({ "doc": re, "output_mode": "editable", "out": "copy.pdf" })) {
Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"),
other => panic!("the editable output is refused: {other:?}"),
}
match a.call("ocr_words", &json!({ "doc": re })) {
Err(ToolError::Failed(m)) => assert!(m.contains("don't allow copying"), "{m}"),
other => panic!("ocr_words is refused: {other:?}"),
}
if ok(&mut a, "ocr_status", json!({}))["available"] != true {
eprintln!("skipped the OCR part: OCR models not installed");
return;
@@ -7502,6 +7513,8 @@ fn ocr_tools_make_a_scan_searchable() {
let mut a = auto(&dir);
let status = ok(&mut a, "ocr_status", json!({}));
assert_eq!(status["languages"][0]["code"], "en");
assert_eq!(status["engines"][0]["id"], "ocrs", "{status}");
assert!(status["engines"][1]["id"] == "tesseract" && status["engines"][1]["available"].is_boolean(), "{status}");
if status["available"] != true {
eprintln!("skipped: OCR models not installed");
return;
@@ -7516,6 +7529,64 @@ fn ocr_tools_make_a_scan_searchable() {
let again = ok(&mut a, "ocr_recognize", json!({ "doc": scan, "pages": [1] }));
assert!(again["pages"][0]["skipped"].is_string(), "{again}");
assert!(matches!(a.call("ocr_recognize", &json!({ "doc": scan, "language": "xx" })), Err(ToolError::InvalidArgs(_))));
// An engine that cannot run is named up front, before anything is read.
if status["engines"][1]["available"] != true {
let why = a.call("ocr_recognize", &json!({ "doc": scan, "engine": "tesseract", "skip_text_pages": false })).unwrap_err();
assert!(matches!(why, ToolError::Failed(_)), "{why}");
assert!(why.to_string().contains("tesseract"), "{why}");
}
// ocr_words: the recognition's words with confidence, band and suspects, changing nothing.
// ocrs reports no confidences, so `auto` + a confidence merge is asked for when the second
// engine is there to supply them; without it confidence and band are honestly null.
ok(&mut a, "doc_close", json!({ "doc": scan, "discard_changes": true }));
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
let mut args = json!({ "doc": scan });
if status["engines"][1]["available"] == true {
args["engine"] = json!("auto");
args["strategy"] = json!("confidence");
}
let words = ok(&mut a, "ocr_words", args);
assert!(words["words"].as_u64().unwrap() >= 4, "{words}");
let first = &words["pages"][0]["words"][0];
assert!(first["text"].is_string() && first["box"].as_array().is_some_and(|b| b.len() == 4), "{words}");
if status["engines"][1]["available"] == true {
let with_confidence = words["pages"][0]["words"]
.as_array()
.expect("words")
.iter()
.filter(|w| w["confidence"].is_number() && w["band"].as_str().is_some())
.count();
assert!(with_confidence >= 4, "the second engine's confidences arrive: {words}");
} else {
assert!(first["confidence"].is_null() && first["band"].is_null() && first["suspect"] == false, "{words}");
}
assert!(a.call("edit_undo", &json!({ "doc": scan })).is_err(), "ocr_words changes nothing, so there is no undo");
}
/// An editable-text recognition writes a NEW file of visible text, never the source.
#[test]
fn ocr_recognize_editable_output_writes_visible_text() {
let dir = workdir("ocr-editable");
let mut a = auto(&dir);
if ok(&mut a, "ocr_status", json!({}))["available"] != true {
eprintln!("skipped: OCR models not installed");
return;
}
let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Editable output words" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] }));
let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": scan, "path": "src/scan.pdf" }));
// The output never lands on the source.
let why = a.call("ocr_recognize", &json!({ "doc": scan, "pages": [1], "output_mode": "editable", "out": "src/scan.pdf" })).unwrap_err();
assert!(why.to_string().contains("source"), "{why}");
let r = ok(&mut a, "ocr_recognize", json!({ "doc": scan, "pages": [1], "output_mode": "editable", "out": "out/ocr.pdf" }));
assert!(r["words"].as_u64().unwrap() >= 3, "{r}");
let ocr = ok(&mut a, "doc_open", json!({ "path": "out/ocr.pdf" }))["doc"].as_u64().unwrap();
// The text is visible (the engine may misread a letter at this dpi — look for a word that
// survives).
let found = ok(&mut a, "text_find", json!({ "doc": ocr, "query": "output" }));
assert!(found["count"].as_u64() >= Some(1), "the new file's text is visible: {found}");
}
/// #1028: `skip_text_pages: false` reads a picture on a page that has its own text, and the
@@ -7558,6 +7629,46 @@ fn ocr_recognize_files_refuses_paths_outside_the_root() {
assert!(!dir.join("out").exists(), "nothing was started");
}
/// Two inputs of one file name from different folders would write the same output in the
/// batch folder: the batch is refused up front, with or without the OCR engines, naming the
/// file, and nothing is written.
#[test]
fn ocr_recognize_files_refuses_inputs_that_share_a_file_name() {
let dir = workdir("ocr-files-same-name");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/scan.pdf" }));
let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/scan.pdf"], "folder": "out" }));
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("scan.pdf") && m.contains("overwrite")), "{r:?}");
assert!(std::fs::read_dir(dir.join("out")).map_or(true, |mut d| d.next().is_none()), "nothing was written");
}
/// `out` belongs to the editable output. A searchable run (the default) writes into the open
/// document, so an `out` is refused rather than silently ignored, with or without the engines.
#[test]
fn ocr_recognize_refuses_an_out_that_nothing_would_use() {
let dir = workdir("ocr-out-ignored");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
let r = a.call("ocr_recognize", &json!({ "doc": doc, "out": "../outside.pdf" }));
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("editable")), "{r:?}");
assert!(!dir.join("../outside.pdf").exists());
}
/// The editable output is a NEW file: an existing `out` is refused before anything runs (with
/// or without the engines), and the file is left as it was.
#[test]
fn ocr_recognize_editable_output_never_replaces_an_existing_file() {
let dir = workdir("ocr-editable-exists");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
std::fs::write(dir.join("taken.pdf"), b"keep me").unwrap();
let r = a.call("ocr_recognize", &json!({ "doc": doc, "output_mode": "editable", "out": "taken.pdf" }));
assert!(matches!(&r, Err(e) if e.to_string().contains("already exists")), "{r:?}");
assert_eq!(std::fs::read(dir.join("taken.pdf")).unwrap(), b"keep me");
}
#[test]
fn ocr_recognize_files_writes_searchable_copies() {
let dir = workdir("ocr-files");
@@ -7571,9 +7682,22 @@ fn ocr_recognize_files_writes_searchable_copies() {
let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": scan, "path": "in/scan.pdf" }));
// A file that already has text: the skip-files option passes it over as its own bucket.
let typed = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Already typed" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": typed, "path": "in/typed.pdf" }));
// Collision pre-flight: the existing out/scan.pdf (written below) stops a rerun.
let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["in/scan.pdf", "missing.pdf"], "folder": "out" }));
assert!(r["files"][0]["words"].as_u64().unwrap() >= 3, "{r}");
assert!(r["files"][1]["error"].is_string(), "{r}");
let why = a.call("ocr_recognize_files", &json!({ "paths": ["in/scan.pdf"], "folder": "out" })).unwrap_err();
assert!(matches!(why, ToolError::Failed(_)), "{why}");
assert!(why.to_string().contains("scan.pdf"), "{why}");
// The skip-files bucket and the low-confidence note.
let r = ok(&mut a, "ocr_recognize_files", json!({ "paths": ["in/typed.pdf", "in/scan.pdf"], "folder": "out2", "skip_text_files": true }));
assert_eq!(r["skipped_files"].as_u64(), Some(1), "{r}");
assert!(r["files"][0]["skipped"].is_string(), "{r}");
assert_eq!(r["recognized"].as_u64(), Some(1), "{r}");
assert!(r["files"][1]["output"].is_string(), "{r}");
let out = ok(&mut a, "doc_open", json!({ "path": "out/scan.pdf" }))["doc"].as_u64().unwrap();
let found = ok(&mut a, "text_find", json!({ "doc": out, "query": "recognition" }));
assert!(found.to_string().contains("\"page\""), "{found}");
@@ -7583,18 +7707,6 @@ fn ocr_recognize_files_writes_searchable_copies() {
/// the second output replaces the first while both results report success. The batch is refused
/// up front, naming the file, before the folder is created, anything is read or the OCR engines
/// load, so the outcome is the same with or without the models installed.
#[test]
fn ocr_recognize_files_refuses_inputs_that_share_a_file_name() {
let dir = workdir("ocr-files-same-name");
let mut a = auto(&dir);
let doc = ok(&mut a, "doc_open", json!({ "path": "a.pdf" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "one/scan.pdf" }));
ok(&mut a, "doc_save", json!({ "doc": doc, "path": "two/scan.pdf" }));
let r = a.call("ocr_recognize_files", &json!({ "paths": ["one/scan.pdf", "two/scan.pdf"], "folder": "out" }));
assert!(matches!(&r, Err(ToolError::InvalidArgs(m)) if m.contains("scan.pdf")), "{r:?}");
assert!(!dir.join("out").exists(), "nothing was started: the output folder was not created");
}
/// File names are compared without regard to case on Windows and macOS (`scan.pdf` and `Scan.PDF`
/// are one file there), and exactly elsewhere, where they are two files and the batch is not
/// refused.
@@ -10770,6 +10882,162 @@ fn simple_rule_scan_drawn(content: &[u8]) -> Vec<u8> {
write_full(&doc, &SaveOptions::default()).unwrap()
}
/// Reviewing headlessly: corrections keep the boxes, a stale or foreign review is refused, and
/// applying consumes the handle (the pages already accepted were applied once).
#[test]
fn ocr_review_corrections_keep_boxes_are_guarded_and_apply_once() {
fn render(a: &mut Automation, doc: u64) -> Vec<u8> {
let mut result = a.call("page_render", &json!({ "doc": doc, "page": 1, "dpi": 72 })).unwrap();
match result.remove(0) {
Content::Png { data, .. } => data,
other => panic!("expected a rendered page, got {other:?}"),
}
}
let dir = workdir("ocr-review");
let mut a = auto(&dir);
if ok(&mut a, "ocr_status", json!({}))["available"] != true {
eprintln!("skipped: OCR models not installed");
return;
}
let text = ok(&mut a, "doc_create", json!({ "from": "text", "text": "Review these original words" }))["doc"].as_u64().unwrap();
ok(&mut a, "doc_export_images", json!({ "doc": text, "folder": "scan", "dpi": 150, "pages": [1] }));
let png = std::fs::read_dir(dir.join("scan")).unwrap().next().unwrap().unwrap().path();
let scan = ok(&mut a, "doc_create", json!({ "from": "images", "paths": [png.to_string_lossy()] }))["doc"].as_u64().unwrap();
let before = render(&mut a, scan);
let found = ok(&mut a, "ocr_words", json!({ "doc": scan, "retain_review": true }));
assert!(found["words"].as_u64().unwrap() >= 3, "{found}");
let review = found["review"].as_u64().unwrap();
assert!(page_text(&mut a, scan).iter().all(String::is_empty));
for args in [
json!({ "doc": text, "review": review }),
json!({ "doc": scan, "review": review, "corrections": [{"page":1,"word":999999,"text":"bad"}] }),
json!({ "doc": scan, "review": review, "corrections": [{"page":1,"word":1,"text":"bad","origin":[0,0]}] }),
json!({ "doc": scan, "review": review, "corrections": [{"page":1,"word":1,"text":"a".repeat(4*1024*1024+1)}] }),
json!({ "doc": scan, "review": review, "corrections": [{"page":1,"word":1,"text":"a"},{"page":1,"word":1,"text":"b"}] }),
] {
assert!(a.call("ocr_review_apply", &args).is_err());
assert!(page_text(&mut a, scan).iter().all(String::is_empty));
}
let result = ok(
&mut a,
"ocr_review_apply",
json!({ "doc": scan, "review": review, "corrections": [{"page":1,"word":1,"text":"日本語"},{"page":1,"word":2,"text":""}] }),
);
assert!(result["applied"].as_u64().unwrap() >= 2);
let extracted = page_text(&mut a, scan).join("\n");
assert!(extracted.contains("日本語"), "{extracted}");
assert_eq!(render(&mut a, scan), before, "corrected text remains invisible at its original geometry");
assert!(a.call("ocr_review_apply", &json!({ "doc": scan, "review": review })).is_err(), "consumed reviews cannot be replayed");
ok(&mut a, "edit_undo", json!({ "doc": scan }));
assert!(page_text(&mut a, scan).iter().all(String::is_empty));
assert!(a.call("edit_undo", &json!({ "doc": scan })).is_err(), "review acceptance is one undo step");
let found = ok(&mut a, "ocr_words", json!({ "doc": scan, "retain_review": true }));
let stale = found["review"].as_u64().unwrap();
ok(&mut a, "page_rotate", json!({ "doc": scan, "pages": [1], "degrees": 90 }));
let error = a.call("ocr_review_apply", &json!({ "doc": scan, "review": stale })).unwrap_err().to_string();
assert!(error.contains("changed since recognition"), "{error}");
assert!(page_text(&mut a, scan).iter().all(String::is_empty));
ok(&mut a, "ocr_review_apply", json!({ "doc": scan, "review": stale, "discard": true }));
assert!(a.call("ocr_review_apply", &json!({ "doc": scan, "review": stale })).is_err());
ok(&mut a, "edit_undo", json!({ "doc": scan }));
let first = ok(&mut a, "ocr_words", json!({ "doc": scan, "retain_review": true }))["review"].as_u64().unwrap();
let second = ok(&mut a, "ocr_words", json!({ "doc": scan, "retain_review": true }))["review"].as_u64().unwrap();
assert_ne!(first, second);
assert!(a.call("ocr_review_apply", &json!({ "doc": scan, "review": first })).is_err());
ok(
&mut a,
"ocr_review_apply",
json!({ "doc": scan, "review": second, "output_mode": "editable", "out": "reviewed.pdf", "corrections": [{"page":1,"word":1,"text":"Corrected"}] }),
);
assert!(page_text(&mut a, scan).iter().all(String::is_empty));
let output = ok(&mut a, "doc_open", json!({ "path": "reviewed.pdf" }))["doc"].as_u64().unwrap();
assert!(page_text(&mut a, output).join("\n").contains("Corrected"));
// The editable output is a new file: an existing one is refused and the review is kept.
let third = ok(&mut a, "ocr_words", json!({ "doc": scan, "retain_review": true }))["review"].as_u64().unwrap();
let again = a.call("ocr_review_apply", &json!({ "doc": scan, "review": third, "output_mode": "editable", "out": "reviewed.pdf" }));
assert!(matches!(&again, Err(e) if e.to_string().contains("already exists")), "{again:?}");
ok(&mut a, "ocr_review_apply", json!({ "doc": scan, "review": third, "discard": true }));
}
#[test]
fn ocr_dictionary_updates_are_atomic_bounded_and_confined() {
let dir = workdir("ocr-dictionary");
let mut a = auto(&dir);
let path = "ocr-dictionary-en.txt";
let out = ok(&mut a, "ocr_dictionary", json!({ "path": path, "add": ["Quartz", "quartz", "日本語"] }));
assert_eq!(out["count"], 2);
assert_eq!(pdfcraft_engine::ocr::dictionary::words(&std::fs::read_to_string(dir.join(path)).unwrap()).unwrap().len(), 2);
let before = std::fs::read(dir.join(path)).unwrap();
for args in [
json!({ "path": path, "add": ["a\nb"] }),
json!({ "path": path, "add": ["x".repeat(1025)] }),
json!({ "path": path, "add": ["İ".repeat(512)] }),
json!({ "path": path, "add": vec!["x"; 65537] }),
json!({ "path": "../outside-ocr-dictionary.txt", "add": ["x"] }),
] {
assert!(a.call("ocr_dictionary", &args).is_err());
}
assert_eq!(std::fs::read(dir.join(path)).unwrap(), before);
assert_eq!(ok(&mut a, "ocr_dictionary", json!({ "path": path, "remove": ["QUARTZ"] }))["words"], json!(["日本語"]));
assert_eq!(ok(&mut a, "ocr_dictionary", json!({ "path": path }))["count"], 1);
std::fs::write(dir.join("ocr-dictionary-large.txt"), vec![b'a'; 4 * 1024 * 1024 + 1]).unwrap();
let too_big = a.call("ocr_dictionary", &json!({ "path": "ocr-dictionary-large.txt", "add": ["x"] })).unwrap_err().to_string();
assert!(!too_big.contains("must name a dictionary file"), "refused for its size, not its name: {too_big}");
assert_eq!(std::fs::metadata(dir.join("ocr-dictionary-large.txt")).unwrap().len(), 4 * 1024 * 1024 + 1);
}
/// The tool rewrites what it is pointed at (lower-cased, sorted), so it only takes dictionary file
/// names: another small text file under the root is neither read nor changed.
#[test]
fn ocr_dictionary_refuses_files_that_are_not_dictionaries() {
let dir = workdir("ocr-dictionary-names");
let mut a = auto(&dir);
let env = b"TOKEN=Zebra
APPLE=1
";
for name in [".env", "notes.txt", "ocr-dictionary-en.md", "ocr-dictionary-e n.txt", "sub/readme.txt"] {
if let Some(parent) = std::path::Path::new(name).parent() {
std::fs::create_dir_all(dir.join(parent)).unwrap();
}
std::fs::write(dir.join(name), env).unwrap();
for args in [json!({ "path": name }), json!({ "path": name, "add": [] }), json!({ "path": name, "add": ["x"] })] {
let err = a.call("ocr_dictionary", &args).unwrap_err().to_string();
assert!(err.contains("must name a dictionary file"), "{name} {args}: {err}");
}
assert_eq!(std::fs::read(dir.join(name)).unwrap(), env, "{name} is untouched");
}
}
#[test]
fn ocr_retained_reviews_have_eight_document_cap_and_close_releases_slot() {
let dir = workdir("ocr-review-cap");
let mut a = auto(&dir);
if ok(&mut a, "ocr_status", json!({}))["available"] != true {
eprintln!("skipped: OCR models not installed");
return;
}
let mut docs = Vec::new();
let mut handles = Vec::new();
for _ in 0..9 {
let doc = ok(&mut a, "doc_create", json!({"from":"text", "text":"Already recognized text"}))["doc"].as_u64().unwrap();
docs.push(doc);
}
for doc in docs.iter().take(8) {
handles.push(ok(&mut a, "ocr_words", json!({"doc":doc,"retain_review":true}))["review"].as_u64().unwrap());
}
let error = a.call("ocr_words", &json!({"doc":docs[8],"retain_review":true})).unwrap_err().to_string();
assert!(error.contains("at most 8"), "{error}");
let replacement = ok(&mut a, "ocr_words", json!({"doc":docs[0],"retain_review":true}))["review"].as_u64().unwrap();
assert_ne!(replacement, handles[0]);
assert!(a.call("ocr_review_apply", &json!({"doc":docs[0],"review":handles[0],"discard":true})).is_err());
ok(&mut a, "doc_close", json!({"doc":docs[0],"discard_changes":true}));
assert!(a.call("ocr_review_apply", &json!({"doc":docs[0],"review":replacement})).is_err());
let final_handle = ok(&mut a, "ocr_words", json!({"doc":docs[8],"retain_review":true}))["review"].as_u64().unwrap();
ok(&mut a, "ocr_review_apply", json!({"doc":docs[8],"review":final_handle,"discard":true}));
}
#[test]
fn editor_crop_group_move_and_arrange_survive_added_item_deletion_through_tools() {
let dir = workdir("editor-crop-group-arrange");
+20
View File
@@ -92,6 +92,26 @@ pub fn blank(width: f64, height: f64, pages: usize) -> Result<Document, CreateEr
Ok(doc)
}
/// A document whose pages hold ready-made content: one `(width, height, resources, content)`
/// tuple per page, in page order. Empty content vectors make blank pages. The OCR Verify
/// screen's editable-text output builds its pages this way.
pub fn from_contents(pages: &[(f64, f64, Dict, Vec<u8>)]) -> Result<Document, CreateError> {
if pages.is_empty() || pages.len() > 10_000 {
return Err(CreateError::Invalid("invalid page count".into()));
}
if pages.iter().any(|(w, h, _, _)| !(w.is_finite() && h.is_finite() && (3.0..=MAX_SIDE).contains(w) && (3.0..=MAX_SIDE).contains(h))) {
return Err(CreateError::Invalid("invalid page size".into()));
}
let mut doc = Document::new_empty();
let mut tree = Pages::of(&doc)?;
for (w, h, resources, content) in pages {
let content = if content.is_empty() { None } else { Some(content.clone()) };
tree.add(&mut doc, *w, *h, resources.clone(), content);
}
tree.finish(&mut doc)?;
Ok(doc)
}
// ── images ──────────────────────────────────────────────────────────────────────────────────
/// An image ready to embed: its XObject dictionary, encoded data, optional soft mask, the ICC
+1 -1
View File
@@ -78,7 +78,7 @@ pub(crate) use place::undrawable;
#[cfg(test)]
use place::{ISOLATION_MAX_DEPTH, OLD_WRAP_CLOSE, WRAP_CLOSE, WRAP_OPEN, is_wrap_close, page_fonts, stream_bytes, stream_tag, tagged, wrapper_role};
use place::{Reach, check, check_on_top, contents, draw_on_top, lift_on_top, page_list, place_tagged, revealed_under_paint};
pub use place::{stamp, undrawable_message};
pub use place::{add_type3_fonts, stamp, stamp_with_fonts, undrawable_message};
#[cfg(test)]
mod marked_isolation_tests;
+65
View File
@@ -478,14 +478,79 @@ pub(crate) fn place_inside_wrapper(doc: &mut Document, page: &pdfcraft_model::Pa
/// it away); the original content is wrapped in q/Q first. Used for applied redaction boxes.
/// Returns the new content stream.
pub fn stamp(doc: &mut Document, page: usize, tag: &str, content: Vec<u8>) -> Result<ObjRef, EditError> {
stamp_with_fonts(doc, page, tag, &mut pdfcraft_fonts::TextPlan::default(), |_| content)
}
/// Draw the content `make` builds on top of a page, with standard Helvetica as `/PCHelv` and
/// the plan's Type3 fonts added to the page's resources first (so the content can reference
/// them by the names the plan carries). The original content is wrapped in q/Q first; the
/// stream is tagged `tag` (not a mark kind, so Remove never takes it away). This is how the
/// OCR text layers get their Unicode faces (see [`add_type3_fonts`]). Returns the new content
/// stream.
pub fn stamp_with_fonts(
doc: &mut Document,
page: usize,
tag: &str,
plan: &mut pdfcraft_fonts::TextPlan,
make: impl FnOnce(&pdfcraft_fonts::TextPlan) -> Vec<u8>,
) -> Result<ObjRef, EditError> {
let all = page_list(doc);
check(&[page], all.len())?;
check_on_top(doc, &all[page])?;
add_type3_fonts(doc, page, plan)?;
let content = make(plan);
let all = page_list(doc);
add_resources(doc, &all[page], None, Some(&content))?;
let p = page_list(doc).swap_remove(page);
place_tagged(doc, &p, tag, content, false)
}
/// Add the plan's Type3 fonts to a page's (0-based) resources, building each font once and
/// sharing the object with every page that uses it. Fills in each planned font's resource name
/// (`PCOcr`, `PCOcr1`, … — a name free on the page being registered: a font built for an
/// earlier page keeps its name only where that name is free or already that font) and object;
/// the content generators read the names from the plan, so this must run before they are
/// called for each page.
pub fn add_type3_fonts(doc: &mut Document, page: usize, plan: &mut pdfcraft_fonts::TextPlan) -> Result<(), EditError> {
if plan.fonts.is_empty() {
return Ok(());
}
let all = page_list(doc);
check(&[page], all.len())?;
let p = &all[page];
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
for i in 0..plan.fonts.len() {
let object = match plan.fonts[i].object {
Some(r) => {
// Built for an earlier page under a name that was free THERE. On this page the
// name may already be another font (the page's own, or an earlier OCR layer's):
// then this page gets a free name of its own, and the plan carries it for the
// content written next.
let taken = fonts.get(plan.fonts[i].name.as_bytes()).is_some_and(|o| o.as_ref() != Some(r));
if taken {
plan.fonts[i].name = super::text::unique_font_name(&fonts, "PCOcr");
}
r
}
None => {
let glyphs = plan.fonts[i].glyphs.iter().map(|(ch, g)| (*ch, super::text::glyph_path(g), g.width)).collect::<Vec<_>>();
let dict = super::text::type3_font_dict(doc, &glyphs)?;
let r = doc.add(Object::Dict(dict));
plan.fonts[i].name = super::text::unique_font_name(&fonts, "PCOcr");
plan.fonts[i].object = Some(r);
r
}
};
fonts.set(plan.fonts[i].name.as_bytes().to_vec(), Object::Ref(object));
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
// The page gets its own (possibly copied) resources; shared dictionaries stay untouched.
let obj = p.obj;
doc.update_dict(obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
Ok(())
}
pub(crate) fn begin(kind: MarkKind, subtype: &str, matrix: [f64; 6]) -> String {
format!(
"q\n/Artifact <</Type /Pagination /Subtype /{subtype} /PCMark /{}>> BDC\n{} {} {} {} {} {} cm\n",
+80
View File
@@ -10257,6 +10257,86 @@ fn styled_existing_justification_is_refused_instead_of_silently_left_aligning()
assert_eq!(page_content_bytes(&reopen(&doc), 0), before);
}
/// The OCR stamp path registers the plan's Type3 faces (a free `PCOcr…` name per stamp, so a
/// page that is OCR'd twice keeps both layers' fonts), and the stamped codes extract back
/// through the font's ToUnicode CMap.
#[test]
fn stamp_with_fonts_registers_the_planned_type3_faces() {
if without_craft_fonts("stamp_with_fonts_registers_the_planned_type3_faces") {
return;
}
let mut doc = text_page("BT /F2 12 Tf 72 700 Td (ab) Tj ET");
let mut plan = pdfcraft_fonts::TextPlan::for_words(["日本"]);
assert_eq!(plan.missing(), 0);
stamp_with_fonts(&mut doc, 0, "OCR", &mut plan, |p| {
let codes: Vec<u8> = p.encoding(0).iter().map(|&(_, c)| c).collect();
let mut c = format!("BT 3 Tr /PCHelv 1 Tf\n/{} 1 Tf 100 700 Td ", p.fonts[0].name).into_bytes();
c.extend_from_slice(&pdfcraft_fonts::literal(&codes));
c.extend_from_slice(b" Tj ET");
c
})
.unwrap();
assert!(plan.fonts[0].name.starts_with("PCOcr"), "{:?}", plan.fonts[0].name);
// The registered font carries a ToUnicode CMap; the engine's own reader decodes the run.
let reopened = Document::open(Arc::new(write_incremental(&doc, &SaveOptions::default()).unwrap())).unwrap();
let lines = text::text_lines(&reopened, 0).unwrap();
assert!(lines.iter().any(|l| l.text.contains("日本")), "{lines:?}");
// A second stamp on the same page takes the next free name; the first layer's font stands.
let mut second = pdfcraft_fonts::TextPlan::for_words(["語"]);
stamp_with_fonts(&mut doc, 0, "OCR", &mut second, |p| format!("BT 3 Tr /{} 1 Tf 100 600 Td (\u{1}) Tj ET", p.fonts[0].name).into_bytes())
.unwrap();
assert_eq!(second.fonts[0].name, format!("{}1", plan.fonts[0].name));
}
/// A Type3 face built for page 1 is reused on page 2, but page 2 already has a font under the
/// name page 1 gave it: page 2's own font keeps its name, and the shared face is registered on
/// page 2 under a free one that the plan carries for page 2's content. (The plan is written out
/// by hand, so this runs without the craft-fonts build input.)
#[test]
fn a_reused_type3_face_never_replaces_a_later_page_s_font_of_the_same_name() {
let mut doc = build(&[
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R 4 0 R] /Count 2 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 5 0 R >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 800] /Contents 6 0 R /Resources << /Font << /PCOcr 7 0 R >> >> >>",
&stream("", ""),
&stream("", "BT /PCOcr 12 Tf 72 700 Td (own) Tj ET"),
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>",
]);
let glyph = pdfcraft_fonts::GlyphOutline { contours: vec![vec![[0.1, 0.0], [0.9, 0.0], [0.5, 0.8]]], width: 1.0, bbox: [0.1, 0.0, 0.9, 0.8] };
let mut plan = pdfcraft_fonts::TextPlan::default();
plan.fonts.push(pdfcraft_fonts::PlannedType3 { family: "Test", glyphs: vec![('日', glyph)], name: String::new(), object: None });
let draw = |p: &pdfcraft_fonts::TextPlan| format!("BT 3 Tr /{} 1 Tf 100 600 Td (\u{1}) Tj ET", p.fonts[0].name).into_bytes();
stamp_with_fonts(&mut doc, 0, "OCR", &mut plan, draw).unwrap();
assert_eq!(plan.fonts[0].name, "PCOcr", "free on page 1");
let face = plan.fonts[0].object.expect("built once");
stamp_with_fonts(&mut doc, 1, "OCR", &mut plan, draw).unwrap();
assert_ne!(plan.fonts[0].name, "PCOcr", "page 2 already has a PCOcr");
let page2 = &pdfcraft_model::pages(&doc)[1];
let res = doc.resolve(page2.dict.get(b"Resources").unwrap());
let fonts = doc.resolve(res.as_dict().unwrap().get(b"Font").unwrap());
let fonts = fonts.as_dict().unwrap();
assert_eq!(fonts.get(b"PCOcr").and_then(|o| o.as_ref()), Some(pdfcraft_cos::ObjRef::new(7, 0)), "page 2's own font keeps its name");
assert_eq!(fonts.get(plan.fonts[0].name.as_bytes()).and_then(|o| o.as_ref()), Some(face), "the shared face, under a free name");
assert_eq!(plan.fonts[0].object, Some(face), "still built once");
}
#[test]
fn planned_type3_charproc_has_valid_metrics_and_encloses_its_ink() {
let glyph = pdfcraft_fonts::GlyphOutline {
contours: vec![vec![[-0.1001, -0.2001], [0.9001, 1.1001]]],
width: 0.8,
bbox: [-0.1001, -0.2001, 0.9001, 1.1001],
};
let parsed = pdfcraft_content::parse(&text::glyph_path(&glyph));
assert_eq!(parsed.skipped, 0);
let metrics = &parsed.ops[0];
assert!(metrics.is("d1"));
assert_eq!(metrics.operands.len(), 6);
assert_eq!((metrics.num(0), metrics.num(1)), (Some(800.0), Some(0.0)));
assert_eq!((metrics.num(2), metrics.num(3), metrics.num(4), metrics.num(5)), (Some(-101.0), Some(-201.0), Some(901.0), Some(1101.0)));
}
#[test]
fn image_arrange_refuses_crossed_scope_boundaries_without_changes() {
for content in ["/P <</MCID 0>> BDC q 10 0 0 10 0 0 cm /Im0 Do EMC Q /Im0 Do", "q /P <</MCID 0>> BDC /Im0 Do Q EMC /Im0 Do"] {
+1
View File
@@ -52,6 +52,7 @@ use fallback::{
};
#[cfg(test)]
use fallback::{add_type3_font, is_type3_fallback};
pub(crate) use fallback::{glyph_path, type3_font_dict, unique_font_name};
use interp::{Origin, Paint, union};
use lines::page_dict;
pub(crate) use lines::{FormBudget, content_streams, content_streams_within, dict_in_place, form_call, resource_dict, rewritten_contents, splice};
+86
View File
@@ -493,6 +493,92 @@ pub(super) fn add_type3_font(doc: &mut Document, fonts_res: &mut Dict, face: &Cr
name
}
/// A glyph's CharProc body at 1000 units per em, with a six-operand `d1` and its actual ink box.
pub(crate) fn glyph_path(glyph: &pdfcraft_fonts::GlyphOutline) -> Vec<u8> {
let scale = 1000.0;
let b = if glyph.contours.is_empty() { [0.0; 4] } else { glyph.bbox };
let mut out = format!(
"{} 0 {} {} {} {} d1\n",
pdf_num(glyph.width * scale),
pdf_num((b[0] * scale).floor()),
pdf_num((b[1] * scale).floor()),
pdf_num((b[2] * scale).ceil()),
pdf_num((b[3] * scale).ceil())
)
.into_bytes();
for contour in &glyph.contours {
let Some(first) = contour.first() else { continue };
out.extend_from_slice(format!("{} {} m\n", pdf_num(first[0] * scale), pdf_num(first[1] * scale)).as_bytes());
for p in contour.iter().skip(1) {
out.extend_from_slice(format!("{} {} l\n", pdf_num(p[0] * scale), pdf_num(p[1] * scale)).as_bytes());
}
out.extend_from_slice(b"h\n");
}
out.extend_from_slice(b"f\n");
out
}
/// A resource name free in `fonts_res`: `base`, then `base1`, `base2`, … (a page that was
/// OCR'd twice keeps both layers' fonts distinct).
pub(crate) fn unique_font_name(fonts_res: &Dict, base: &str) -> String {
let mut name = String::from(base);
let mut suffix = 0usize;
while fonts_res.contains(name.as_bytes()) {
suffix = suffix.saturating_add(1);
name = format!("{base}{suffix}");
}
name
}
/// The Type3 font dictionary for `(character, CharProc body, advance in em)` glyphs, codes from
/// 1: one CharProc per code, `/Differences` glyph names, real `/Widths`, and a `/ToUnicode` CMap
/// so the text extracts back (this is what makes the output Unicode-correct, not just visible).
pub(crate) fn type3_font_dict(doc: &mut Document, glyphs: &[(char, Vec<u8>, f64)]) -> Result<Dict, EditError> {
let mut charprocs = Dict::new();
let mut differences = vec![Object::Int(1)];
let mut widths = Vec::with_capacity(glyphs.len());
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
cmap.push_str(&format!("{} beginbfchar\n", glyphs.len()));
let mut codes = Vec::with_capacity(glyphs.len());
for (i, (ch, path, width)) in glyphs.iter().enumerate() {
let code = u8::try_from(i + 1).map_err(|_| EditError::Invalid("too many unique characters for one font".into()))?;
let glyph_name = format!("g{code:02X}");
let mut pd = Dict::new();
pd.set(b"Length".to_vec(), path.len() as i64);
let proc_ref = doc.add(Object::Stream(Stream::from_raw(pd, path.clone())));
charprocs.set(glyph_name.as_bytes().to_vec(), Object::Ref(proc_ref));
differences.push(Object::name(&glyph_name));
widths.push(Object::Real((width * 1000.0).round()));
cmap.push_str(&format!("<{code:02X}> <{}>\n", unicode_hex(*ch)));
codes.push((*ch, code, *width));
}
cmap.push_str("endbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let mut cmap_dict = Dict::new();
cmap_dict.set(b"Length".to_vec(), cmap.len() as i64);
let cmap_ref = doc.add(Object::Stream(Stream::from_raw(cmap_dict, cmap.into_bytes())));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"Name".to_vec(), Object::name("PCFallback"));
font.set(b"FontBBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(-300), Object::Int(1000), Object::Int(1000)]));
font.set(
b"FontMatrix".to_vec(),
Object::Array(vec![Object::Real(0.001), Object::Int(0), Object::Int(0), Object::Real(0.001), Object::Int(0), Object::Int(0)]),
);
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(codes.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(widths));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(charprocs));
font.set(b"ToUnicode".to_vec(), Object::Ref(cmap_ref));
Ok(font)
}
/// The runs that show `text` in the fallback: consecutive characters in one font form one run, so
/// a font is switched only where the text moves to another font. `None` if a character has no code.
pub(super) fn type3_encode(fallback: &Type3Fallback, text: &str) -> Option<Vec<(String, Vec<u8>)>> {
+1 -1
View File
@@ -267,7 +267,7 @@ pub static TOOL_GROUPS: &[ToolGroup] = &[
item("In multiple files", "files", "ocr.recognize_batch", Ready),
item("Enhance scanned file", "sparkles", "ocr.enhance", Ready),
item("Enhance camera image", "camera", "ocr.enhance_camera", Ready),
item("Correct recognized text", "text-select", "ocr.correct", Planned("M10")),
item("Correct recognized text", "text-select", "ocr.correct", Ready),
],
}],
},
+1
View File
@@ -361,6 +361,7 @@ pub const COMMANDS: &[CommandSpec] = &[
c("ocr.enhance_camera", "Enhance camera image", None, None, Modification, "camera"),
c("ocr.enhance", "Enhance scanned file…", None, None, Modification, "sparkles"),
c("ocr.recognize_batch", "Recognize text in multiple files…", None, None, Nothing, "files"),
c("ocr.correct", "Correct recognized text…", None, None, Document, "text-select"),
c("a11y.check", "Check for accessibility…", None, None, Document, "accessibility"),
c("a11y.find_untagged", "Find untagged content", None, None, Document, "search"),
c("a11y.autotag", "Automatically tag PDF", None, None, Document, "tag"),
+1 -1
View File
@@ -718,7 +718,7 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
Edit::RemoveStandardId { standard } => pdfcraft_organize::remove_standard_id(doc, *standard)?,
Edit::SetDocumentScript { name, script } => pdfcraft_forms::set_document_script(doc, name, script.as_deref())?,
Edit::AddOcrText { page, words } => {
pdfcraft_edit::stamp(doc, *page, "OCR", pdfcraft_ocr::text_layer(words))?;
crate::ocr::layer::add_text(doc, *page, words)?;
}
Edit::EnhanceScan { images } => crate::scan_clean::write_back(doc, images)?,
Edit::EditPageImage { page, index, change } => run_edit_page_image(doc, *page, *index, change)?,
+1741 -68
View File
File diff suppressed because it is too large. Load diff
+80
View File
@@ -0,0 +1,80 @@
//! The UI dictionary format: one case-insensitive word per UTF-8 line.
use std::collections::BTreeSet;
pub const MAX_BYTES: usize = 4 * 1024 * 1024;
const MAX_WORDS: usize = 65536;
fn word(value: &str) -> Result<String, String> {
let value = value.trim();
if value.is_empty() || value.len() > 1024 || value.chars().any(char::is_control) {
return Err("dictionary words must be nonempty single lines of at most 1024 bytes".into());
}
let normalized = value.to_lowercase();
if normalized.len() > 1024 {
return Err("normalized dictionary words must be at most 1024 bytes".into());
}
Ok(normalized)
}
pub fn words(text: &str) -> Result<BTreeSet<String>, String> {
if text.len() > MAX_BYTES {
return Err("OCR dictionary exceeds 4 MiB".into());
}
let mut words = BTreeSet::new();
for line in text.lines().filter(|line| !line.trim().is_empty()) {
words.insert(word(line)?);
if words.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
Ok(words)
}
/// Normalize, update and bound the complete dictionary before the caller writes it.
pub fn update(text: &str, add: &[&str], remove: &[&str]) -> Result<String, String> {
if add.len() > MAX_WORDS || remove.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
let mut entries = words(text)?;
for value in remove {
entries.remove(&word(value)?);
}
for value in add {
entries.insert(word(value)?);
if entries.len() > MAX_WORDS {
return Err("OCR dictionary exceeds 65536 words".into());
}
}
let mut out = String::new();
for entry in entries {
if out.len().checked_add(entry.len()).and_then(|n| n.checked_add(1)).is_none_or(|n| n > MAX_BYTES) {
return Err("OCR dictionary exceeds 4 MiB".into());
}
out.push_str(&entry);
out.push('\n');
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dictionary_updates_round_trip_and_refuse_expanding_normalization() {
let text = update("Quartz\n日本語\n", &["QUARTZ", "extra"], &["日本語"]).unwrap();
assert_eq!(text, "extra\nquartz\n");
assert_eq!(words(&text).unwrap(), BTreeSet::from(["extra".into(), "quartz".into()]));
assert!(update("original\n", &[&"İ".repeat(512)], &[]).unwrap_err().contains("normalized"));
assert!(update("original\n", &["two\nlines"], &[]).is_err());
}
#[test]
fn dictionary_limits_cover_existing_and_added_entries() {
let full: String = (0..65536).map(|i| format!("word{i}\n")).collect();
assert_eq!(words(&full).unwrap().len(), 65536);
assert!(update(&full, &["extra"], &[]).is_err());
assert_eq!(update(&full, &["extra"], &["word0"]).unwrap().lines().count(), 65536);
assert!(words(&"a".repeat(MAX_BYTES + 1)).is_err());
}
}
+145
View File
@@ -0,0 +1,145 @@
//! Unicode search text has no visible glyphs. A generated Type 3 font supplies advance widths
//! and a ToUnicode map, with empty character programs; no display font is needed or embedded.
//! Adapted from the searchable-layer implementation in #433 (32e09fbe).
use std::collections::BTreeMap;
use pdfcraft_cos::{Dict, Document, Object, Stream};
use pdfcraft_edit::EditError;
use pdfcraft_ocr::PlacedWord;
/// Refuse oversized text or invalid geometry before cloning words or allocating fonts.
/// These limits apply to the complete input, including all editable export pages.
pub(crate) fn validate_words<'a>(words: impl IntoIterator<Item = &'a PlacedWord>) -> Result<usize, EditError> {
let mut count = 0usize;
let mut length = 0usize;
for w in words {
count += 1;
length = length.checked_add(w.text.len()).ok_or_else(|| EditError::Invalid("OCR text layer is too large".into()))?;
if count > 65536 || length > 4 * 1024 * 1024 {
return Err(EditError::Invalid("OCR text layer is too large".into()));
}
if !w.origin.iter().chain(&w.across).chain(&w.up).all(|v| v.is_finite())
|| !w.across[0].hypot(w.across[1]).is_finite()
|| !w.up[0].hypot(w.up[1]).is_finite()
|| (0..2).any(|i| !(w.origin[i] + 0.2 * w.up[i]).is_finite() || !(w.origin[i] + w.across[i] + 0.2 * w.up[i]).is_finite())
{
return Err(EditError::Invalid("OCR text coordinates must be finite".into()));
}
}
Ok(count)
}
pub(crate) fn add_text(doc: &mut Document, page: usize, words: &[PlacedWord]) -> Result<(), EditError> {
validate_words(words)?;
// Keep the existing Latin layout, including Helvetica's proportional advance widths.
if words.iter().all(|w| {
w.text
.chars()
.all(|c| matches!(c, '\u{20}'..='\u{7e}' | '\u{a0}'..='\u{ff}' | '€' | '‚' | '„' | '…' | '‘' | '’' | '“' | '”' | '•' | '–' | '—' | '™'))
}) {
pdfcraft_edit::stamp(doc, page, "OCR", pdfcraft_ocr::text_layer(words, &pdfcraft_fonts::TextPlan::default()))?;
return Ok(());
}
let p = pdfcraft_model::pages(doc).get(page).cloned().ok_or_else(|| EditError::Invalid("OCR page does not exist".into()))?;
let mut res = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
let mut fonts = res.get(b"Font").map(|f| doc.resolve(f)).and_then(|f| f.as_dict().cloned()).unwrap_or_default();
let chars: Vec<char> = words.iter().flat_map(|w| w.text.chars()).collect::<std::collections::BTreeSet<_>>().into_iter().collect();
// Match the extractor's em box (-200..800). Baselines below are offset by 0.2 em,
// so search/selection rectangles span the detected image box.
let blank = doc.add(Object::Stream(Stream::from_raw(Dict::new(), b"1000 0 0 -200 1000 800 d1\n".to_vec())));
let mut codes = BTreeMap::new();
let mut names = Vec::new();
for (index, chunk) in chars.chunks(255).enumerate() {
let mut name = format!("PCOcr{index}");
while fonts.contains(name.as_bytes()) {
name.push('x');
}
let mut differences = vec![Object::Int(1)];
let mut procs = Dict::new();
let mut cmap = String::from(
"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CIDSystemInfo << /Registry (PdfCraft) /Ordering (Unicode) /Supplement 0 >> def\n/CMapName /PdfCraftOCR def\n/CMapType 2 def\n1 begincodespacerange\n<01> <FF>\nendcodespacerange\n",
);
for (batch, part) in chunk.chunks(100).enumerate() {
cmap.push_str(&format!("{} beginbfchar\n", part.len()));
for (i, ch) in part.iter().enumerate() {
let code = (batch * 100 + i + 1) as u8;
let glyph = format!("g{code:02X}");
differences.push(Object::name(&glyph));
procs.set(glyph.into_bytes(), Object::Ref(blank));
let mut units = [0u16; 2];
let hex: String = ch.encode_utf16(&mut units).iter().map(|u| format!("{u:04X}")).collect();
cmap.push_str(&format!("<{code:02X}> <{hex}>\n"));
codes.insert(*ch, (index, code));
}
cmap.push_str("endbfchar\n");
}
cmap.push_str("endcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n");
let map = doc.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
let mut encoding = Dict::new();
encoding.set(b"Type".to_vec(), Object::name("Encoding"));
encoding.set(b"Differences".to_vec(), Object::Array(differences));
let mut font = Dict::new();
font.set(b"Type".to_vec(), Object::name("Font"));
font.set(b"Subtype".to_vec(), Object::name("Type3"));
font.set(b"FontBBox".to_vec(), Object::Array(vec![0.into(), (-200).into(), 1000.into(), 800.into()]));
font.set(b"FontMatrix".to_vec(), Object::Array(vec![Object::Real(0.001), 0.into(), 0.into(), Object::Real(0.001), 0.into(), 0.into()]));
font.set(b"FirstChar".to_vec(), Object::Int(1));
font.set(b"LastChar".to_vec(), Object::Int(chunk.len() as i64));
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(1000); chunk.len()]));
font.set(b"Encoding".to_vec(), Object::Dict(encoding));
font.set(b"CharProcs".to_vec(), Object::Dict(procs));
font.set(b"Resources".to_vec(), Object::Dict(Dict::new()));
font.set(b"ToUnicode".to_vec(), Object::Ref(map));
fonts.set(name.as_bytes().to_vec(), Object::Ref(doc.add(Object::Dict(font))));
names.push(name);
}
res.set(b"Font".to_vec(), Object::Dict(fonts));
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: like
// `pdfcraft_ocr::text_layer` (#3029), the text is drawn inside `q … Q` so the invisible
// rendering mode cannot leak into later content streams on the page.
let mut content = String::from("/OCR BMC\nq\nBT\n3 Tr\n");
for w in words {
let count = w.text.chars().count();
if count == 0 || w.up[0].hypot(w.up[1]) <= 0.0 || w.across[0].hypot(w.across[1]) <= 0.0 {
continue;
}
let mut start = 0;
let mut run = Vec::new();
let mut font_index = None;
let emit = |content: &mut String, font: usize, start: usize, run: &[u8]| {
let n = count as f64;
let at = start as f64 / n;
let hex: String = run.iter().map(|b| format!("{b:02X}")).collect();
content.push_str(&format!(
"/{} 1 Tf\n{} {} {} {} {} {} Tm <{hex}> Tj\n",
names[font],
w.across[0] / n,
w.across[1] / n,
w.up[0],
w.up[1],
w.origin[0] + at * w.across[0] + 0.2 * w.up[0],
w.origin[1] + at * w.across[1] + 0.2 * w.up[1]
));
};
for (i, ch) in w.text.chars().enumerate() {
let Some(&(font, code)) = codes.get(&ch) else { continue };
if let Some(previous) = font_index
&& previous != font
{
emit(&mut content, previous, start, &run);
run.clear();
start = i;
}
font_index = Some(font);
run.push(code);
}
if let Some(font) = font_index {
emit(&mut content, font, start, &run);
}
}
content.push_str("ET\nQ\nEMC\n");
pdfcraft_edit::stamp(doc, page, "OCR", content.into_bytes())?;
Ok(())
}
+6 -2
View File
@@ -186,10 +186,14 @@ impl Session {
if !ocr::available() {
return Vec::new();
}
let (Ok(engine), Some(job)) = (ocr::engine(), self.ocr_job(id, &[page], ocr::OcrSettings::default())) else {
let settings = ocr::OcrSettings::default();
let Some(job) = self.ocr_job(id, &[page], settings.clone()) else {
return Vec::new();
};
job.run(&engine, |_, _| true)
let Ok(recognizers) = ocr::recognizers(&settings) else {
return Vec::new();
};
job.run(&recognizers, |_, _| true)
.into_iter()
.flat_map(|p| p.words)
.filter_map(|w| {
+1 -1
View File
@@ -397,7 +397,7 @@ impl Session {
/// A new file (Combine, Extract, Split, Create) as bytes, checked like a save before anyone
/// writes it (#1698, #2166): it reopens, and shows the pages it was built from.
fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
pub(crate) fn write_new(&self, doc: &pdfcraft_cos::Document) -> Result<Arc<Vec<u8>>, EditError> {
let bytes = self.write_unchecked(doc)?;
let expected = pdfcraft_organize::page_count(doc)?;
pdfcraft_cos::Document::open(bytes.clone()).map_err(|e| EditError::Reopen(e.to_string()))?;
+9 -6
View File
@@ -628,7 +628,7 @@ fn a_3d_model_needs_the_comments_permission_and_changes_the_comments() {
assert_eq!(crate::history::scope_of(&edit), crate::history::Scope::Comments);
}
fn page_texts(s: &Session, id: DocId) -> Vec<String> {
pub(crate) fn page_texts(s: &Session, id: DocId) -> Vec<String> {
let doc = s.get(id).unwrap();
let config = pdfcraft_render::RenderConfig { password: doc.password.as_deref().map(Arc::from), ..Default::default() };
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), config);
@@ -7322,12 +7322,13 @@ fn datasets_streams(bytes: &Arc<Vec<u8>>) -> usize {
/// and noted.
#[test]
fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
// On open: a calculate setting its own value 50 000 times, an initialize script showing a
// thousand messages and asking to print, open a link and save.
// On open: a calculate setting its own value 20 000 times (50 000 sat right at the 1 s
// unasked-script timeout and abandoned whenever the test machine was loaded), an
// initialize script showing a thousand messages and asking to print, open a link and save.
let tpl = pdfcraft_xfa::fixtures::scripted_template()
.replace(
r#"<calculate><script contentType="application/x-javascript">qty.rawValue * price.rawValue</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 50000; i++) this.rawValue = i;</script></calculate>"#,
r#"<calculate><script contentType="application/x-javascript">for (var i = 0; i &lt; 20000; i++) this.rawValue = i;</script></calculate>"#,
)
.replace(
r#"if (qty.rawValue === null) qty.rawValue = 2;"#,
@@ -7337,9 +7338,11 @@ fn hostile_xfa_open_scripts_are_merged_capped_kept_from_printing_and_noted() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let started = std::time::Instant::now();
let id = s.open("hostile.pdf", None, bytes, None).expect("opens");
assert!(started.elapsed() < std::time::Duration::from_secs(5), "{:?}", started.elapsed());
// 30 s and not 5 s: the run is wall-clock timed and a loaded test machine (all suites in
// parallel) can take several seconds for the 20 000 iterations; a real hang still fails.
assert!(started.elapsed() < std::time::Duration::from_secs(30), "{:?}", started.elapsed());
let doc = s.get(id).unwrap();
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["49999".to_string()]);
assert_eq!(doc.form.iter().find(|f| f.name == "total").unwrap().value, vec!["19999".to_string()]);
// One datasets stream for the scripts' revision, not one per value set.
assert!(datasets_streams(&doc.bytes) <= 3, "{} datasets streams", datasets_streams(&doc.bytes));
// What opening changed is noted with the form's other XFA notes.
+17 -3
View File
@@ -12,7 +12,14 @@ const WORD: [u32; 4] = [40, 40, 100, 60];
/// A 200 × 100 pt "scan" on tinted paper, with OCR text over its word.
fn scanned() -> (Session, pdfcraft_engine::DocId) {
scanned_with(vec![PlacedWord { text: "John".into(), origin: [40.0, 40.0], across: [60.0, 0.0], up: [0.0, 20.0] }])
scanned_with(vec![PlacedWord {
text: "John".into(),
origin: [40.0, 40.0],
across: [60.0, 0.0],
up: [0.0, 20.0],
confidence: None,
source: "ocrs".into(),
}])
}
fn scanned_with(words: Vec<PlacedWord>) -> (Session, pdfcraft_engine::DocId) {
@@ -86,11 +93,18 @@ fn an_explicit_colour_is_kept_for_the_new_text() {
/// descender line when it has descenders, up to the cap height.
fn scanned_word(text: &str, x0: f64, x1: f64, baseline: f64, size: f64) -> PlacedWord {
let low = if text.chars().any(|c| "gjpqy".contains(c)) { 0.21 * size } else { 0.0 };
PlacedWord { text: text.into(), origin: [x0, baseline - low], across: [x1 - x0, 0.0], up: [0.0, 0.718 * size + low] }
PlacedWord {
text: text.into(),
origin: [x0, baseline - low],
across: [x1 - x0, 0.0],
up: [0.0, 0.718 * size + low],
confidence: None,
source: "ocrs".into(),
}
}
fn word(text: &str, x0: f64, x1: f64, baseline: f64) -> PlacedWord {
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0] }
PlacedWord { text: text.into(), origin: [x0, baseline], across: [x1 - x0, 0.0], up: [0.0, 10.0], confidence: None, source: "ocrs".into() }
}
/// OCR words keep the scan's layout: a phrase is one paragraph, a value in the next table cell and
+5
View File
@@ -59,7 +59,12 @@ set). Without that variable `CRAFT_FONTS` is empty and everything below copes:
and shaped Thai Add Text content. Harfrust positions stacked vowel/tone marks and decomposes
Sara Am; cluster ToUnicode entries preserve the original text. The shared Type 3 writer
embeds outlines, so reopening does not depend on installed fonts.
- `craft_glyph(face, ch)` and `face_has(bytes, ch)`: bounded outlines and glyph coverage for any bundled face.
- `SHIPPORI_MINCHO`: Shippori Mincho's bytes, or `None`.
- `TextPlan::for_words`: how the OCR output writes text beyond WinAnsi — per word the first
face (document face first) whose glyphs cover it, planned once per page into Type 3 fonts
(codes, advances, `?` stand-ins) that `pdfcraft-edit` registers and the `pdfcraft-ocr` layers
draw with; characters no face has are counted (`missing`) so the output reports them.
Generated Japanese Type 3 fonts record the fallback face's family and style in an indirect
`FontDescriptor`. `pdf::Metrics::base_font` uses its `FontName` when the Type 3 dictionary has
+14 -1
View File
@@ -14,6 +14,7 @@ pub mod embedding;
mod encodings;
pub mod fallback;
pub mod pdf;
pub mod plan;
mod script;
mod shaping;
pub mod source_shaping;
@@ -24,8 +25,10 @@ pub use craft::{
document_japanese_font_for_style, document_japanese_fonts_for_style, document_latin_fonts, document_thai_font, ui_arabic_fonts, ui_chinese_fonts,
ui_cjk_fonts, ui_devanagari_fonts, ui_hebrew_fonts, ui_japanese_fonts, ui_telugu_fonts, ui_thai_fonts,
};
pub use plan::{MAX_PLAN_GLYPHS, MAX_TYPE3_GLYPHS, PlannedType3, TextPlan};
pub use script::{
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, japanese_glyph, japanese_glyph_from, script_missing, script_outline,
GlyphError, GlyphOutline, INTER_REGULAR, MAX_SIGNATURE_CHARS, ScriptOutline, craft_glyph, face_has, japanese_glyph, japanese_glyph_from,
script_missing, script_outline,
};
pub use shaping::{
ShapedCluster, ShapedScript, arabic_glyph, arabic_has, hebrew_has, shape_arabic, shape_hebrew, shape_thai, shaped_glyph, thai_has,
@@ -273,6 +276,16 @@ pub fn set_undrawn_mark(d: &mut pdfcraft_cos::Dict, c: Option<char>) {
}
}
/// The byte used by Unicode text plans, from the shared WinAnsi table (tabs become spaces).
pub fn win_ansi_char(c: char) -> Option<u8> {
if c == '\t' { Some(b' ') } else { win_ansi_byte(c) }
}
/// Whether every character can be represented without substitution in WinAnsi.
pub fn fits_win_ansi(s: &str) -> bool {
s.chars().all(win_ansi_encodable)
}
/// Bytes as a PDF literal string, `(` … `)`, with delimiters escaped.
pub fn literal(bytes: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(bytes.len() + 2);
+338
View File
@@ -0,0 +1,338 @@
//! Planning visible Unicode text output for OCR: which characters the standard WinAnsi
//! fonts show as is, which need a Type3 font drawn from the craft-fonts faces, and which have
//! no glyph anywhere (written as `?` — and counted, never silently dropped).
//!
//! The plan is a pure function of the words ([`TextPlan::for_words`]). The content generators
//! (`pdfcraft_ocr::text_layer` / `visible_text_layer`) read the per-character codes and
//! advances from it, and whoever registers the fonts (`pdfcraft_edit::add_type3_fonts`) fills
//! in each face's resource name and object. The build must work without craft-fonts too: there
//! [`CRAFT_FONTS`] is empty, nothing can be planned, and every non-WinAnsi character is
//! reported as a substitution instead.
use std::collections::HashMap;
use pdfcraft_cos::ObjRef;
use crate::{CraftFont, GlyphOutline, craft_glyph, document_japanese_font, face_has, fits_win_ansi, win_ansi_char};
/// Unique characters per Type3 font: the same bound the text-editing fallback uses, so one
/// hostile word cannot balloon into thousands of glyph streams. A face with more characters
/// simply gets another font.
pub const MAX_TYPE3_GLYPHS: usize = 240;
/// Planned glyphs per plan in total (10 full fonts). A page's recognition cannot realistically
/// exceed it; hostile input beyond it degrades to `?` substitutions, which are counted.
pub const MAX_PLAN_GLYPHS: usize = 2400;
/// One planned Type3 font: the glyphs of one craft-fonts face, one character per code
/// (`code = position + 1`, as in the text-editing fallback). `name` and `object` are filled in
/// when the font is added to a page's resources; until then the content generators fall back to
/// the WinAnsi path for the words that reference it.
pub struct PlannedType3 {
/// The face family the glyphs are drawn from ("Shippori Mincho").
pub family: &'static str,
/// (character, outline with its advance) per code, in code order.
pub glyphs: Vec<(char, GlyphOutline)>,
/// The resource name the content references (`PCOcr`, `PCOcr1`, …); empty until registered.
pub name: String,
/// The built font object, shared by every page that uses the font; set at registration.
pub object: Option<ObjRef>,
}
impl PlannedType3 {
fn new(family: &'static str) -> Self {
PlannedType3 { family, glyphs: Vec::new(), name: String::new(), object: None }
}
fn push(&mut self, ch: char, glyph: GlyphOutline) -> u8 {
self.glyphs.push((ch, glyph));
self.glyphs.len() as u8
}
}
/// How the text layers write a plan's characters: each planned character is a
/// `(font index, code)` pair in its font's encoding; a character no face had a glyph for is
/// either the font's `?` code or absent from the run (both counted in [`TextPlan::missing`]).
#[derive(Default)]
pub struct TextPlan {
/// One planned font per face chunk, in first-use order.
pub fonts: Vec<PlannedType3>,
/// Per input word: `(font, code)` per character; empty for a word the standard WinAnsi
/// fonts write as is (or for which nothing could be planned).
words: Vec<Vec<(usize, u8)>>,
/// character → where its glyph lives, across all fonts (codes are font-local).
index: HashMap<char, (usize, u8)>,
/// Characters with no code in their word's font: they appear as `?` (or, when not even a
/// `?` glyph exists, are left out) and are reported, never silently lost.
missing: usize,
}
impl TextPlan {
/// Plan the output for `words`, in order: characters the WinAnsi fonts can't show get codes
/// in Type3 fonts built from the craft-fonts faces (the document face preferred, then the
/// rest of the build input in manifest order); a word whose face can't show a character
/// substitutes its `?`. Faces are chosen per word, so a word of one script stays one face;
/// characters already planned for an earlier word keep their code. Built without
/// craft-fonts, the plan reports every non-WinAnsi character as a substitution.
pub fn for_words<'a>(words: impl IntoIterator<Item = &'a str>) -> TextPlan {
let faces = document_faces();
let mut coverage = Coverage::default();
let mut plan = TextPlan::default();
for word in words {
if fits_win_ansi(word) {
// The WinAnsi path substitutes '?' behind the caller's back; count it.
plan.missing += word.chars().filter(|&c| win_ansi_char(c).is_none()).count();
plan.words.push(Vec::new());
continue;
}
let Some(face) = choose_face(&faces, word, &mut coverage) else {
// No face for this word's script: the WinAnsi path writes '?' for what it
// can't encode.
plan.missing += word.chars().filter(|&c| win_ansi_char(c).is_none()).count();
plan.words.push(Vec::new());
continue;
};
let mut enc: Vec<(usize, u8)> = Vec::with_capacity(word.len());
let mut run: Option<usize> = None;
for ch in word.chars() {
if let Some(&where_) = plan.index.get(&ch) {
enc.push(where_);
run = Some(where_.0);
continue;
}
if let Ok(glyph) = craft_glyph(face, ch)
&& plan.planned_glyphs() < MAX_PLAN_GLYPHS
&& let Some((fi, code)) = plan.add_glyph(face, ch, glyph)
{
plan.index.insert(ch, (fi, code));
enc.push((fi, code));
run = Some(fi);
continue;
}
// The face has no glyph for the character (or the plan is full): substitute
// the font's '?', in the font the surrounding run is set in.
if let Some((fi, code)) = plan.question_mark(face, run.filter(|&f| plan.fonts[f].family == face.family)) {
enc.push((fi, code));
run = Some(fi);
}
plan.missing += 1;
}
plan.words.push(enc);
}
plan
}
/// The encoding of word `i` (parallel to the slice [`Self::for_words`] planned): a list of
/// `(font, code)` per character, or an empty slice for a word the WinAnsi fonts write.
/// Out-of-range indexes degrade to the empty slice rather than panicking — callers pass the
/// same word list they planned with.
pub fn encoding(&self, word: usize) -> &[(usize, u8)] {
self.words.get(word).map(Vec::as_slice).unwrap_or_default()
}
/// How many characters no font could show (they appear as `?`, or are left out when the
/// word's face has no `?` glyph either).
pub fn missing(&self) -> usize {
self.missing
}
/// The advance of one planned code, in em units (the Type3 glyphs' own widths). `0.0` for
/// anything out of range.
pub fn advance(&self, font: usize, code: u8) -> f64 {
self.fonts.get(font).and_then(|f| f.glyphs.get(usize::from(code).checked_sub(1)?)).map_or(0.0, |(_, g)| g.width)
}
/// The sum of the advances of a word's planned codes, in em units.
pub fn advance_of(&self, encoding: &[(usize, u8)]) -> f64 {
encoding.iter().map(|&(f, c)| self.advance(f, c)).sum()
}
/// Whether every font the encoding references has been registered (has a resource name).
/// Unregistered plans fall back to the WinAnsi path, the same output as before the
/// Unicode mapping existed.
pub fn registered(&self, encoding: &[(usize, u8)]) -> bool {
encoding.iter().all(|&(f, _)| self.fonts.get(f).is_some_and(|font| !font.name.is_empty()))
}
/// Characters with a planned glyph so far.
fn planned_glyphs(&self) -> usize {
self.fonts.iter().map(|f| f.glyphs.len()).sum()
}
/// Add a glyph to the face's current font (a fresh one when that font is full).
fn add_glyph(&mut self, face: &CraftFont, ch: char, glyph: GlyphOutline) -> Option<(usize, u8)> {
let fi = self.font_of_face(face.family)?;
let code = self.fonts[fi].push(ch, glyph);
Some((fi, code))
}
/// The index of the face's open font, or a fresh one. `None` without craft-fonts (the
/// face itself comes from there, so this cannot happen for a planned face).
fn font_of_face(&mut self, family: &'static str) -> Option<usize> {
if let Some(fi) = self.fonts.iter().rposition(|f| f.family == family && f.glyphs.len() < MAX_TYPE3_GLYPHS) {
return Some(fi);
}
if self.fonts.len() >= MAX_PLAN_GLYPHS / MAX_TYPE3_GLYPHS {
return None;
}
self.fonts.push(PlannedType3::new(family));
Some(self.fonts.len() - 1)
}
/// The code of the font's `?` stand-in, adding the glyph when the font lacks it.
/// `prefer` is the run's current font; `None` without craft-fonts.
fn question_mark(&mut self, face: &CraftFont, prefer: Option<usize>) -> Option<(usize, u8)> {
if let Some(fi) = prefer
&& let Some(code) = self.fonts[fi].glyphs.iter().position(|(c, _)| *c == '?')
{
return Some((fi, code as u8 + 1));
}
let fi = self.font_of_face(face.family)?;
if let Some(code) = self.fonts[fi].glyphs.iter().position(|(c, _)| *c == '?') {
return Some((fi, code as u8 + 1));
}
let glyph = craft_glyph(face, '?').ok()?;
let code = self.fonts[fi].push('?', glyph);
Some((fi, code))
}
}
/// The faces that can draw document text, in preference order: the Japanese document face
/// (serif document text) first, then the rest of the build input in manifest order.
fn document_faces() -> Vec<&'static CraftFont> {
let mut out = Vec::new();
if let Some(d) = document_japanese_font() {
out.push(d);
}
for f in crate::CRAFT_FONTS {
if !out.iter().any(|d| std::ptr::eq(*d, f)) {
out.push(f);
}
}
out
}
/// The face for a word: the first (in preference order) whose glyphs cover every character,
/// else the one covering the most characters (ties go to the earlier face). `None` when no
/// face covers any character of the word.
fn choose_face<'f>(faces: &[&'f CraftFont], word: &str, coverage: &mut Coverage) -> Option<&'f CraftFont> {
if let Some(f) = faces.iter().enumerate().find(|(i, f)| word.chars().all(|c| coverage.has(*i, f, c))).map(|(_, f)| *f) {
return Some(f);
}
faces
.iter()
.enumerate()
.map(|(i, f)| (word.chars().filter(|&c| coverage.has(i, f, c)).count(), std::cmp::Reverse(i), *f))
.max_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)))
.filter(|(covered, _, _)| *covered > 0)
.map(|(_, _, f)| f)
}
/// Which faces have a glyph for which characters, remembered for one plan: a page of words
/// asks the same (face, character) pairs over and over, and each fresh answer parses the font.
#[derive(Default)]
struct Coverage {
known: std::collections::HashMap<(usize, char), bool>,
lookups: usize,
}
impl Coverage {
/// Whether face number `i` (`face`) has `ch`; asks the font once per pair.
fn has(&mut self, i: usize, face: &CraftFont, ch: char) -> bool {
*self.known.entry((i, ch)).or_insert_with(|| {
self.lookups += 1;
face_has(face.bytes, ch)
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Face coverage is asked once per (face, character): repeating words cost no new font
/// lookups.
#[test]
fn face_coverage_is_remembered_per_face_and_character() {
let faces = document_faces();
let Some(face) = faces.first() else {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
};
let mut coverage = Coverage::default();
let first = (0..50).map(|_| choose_face(&faces, "日本語", &mut coverage)).last().flatten();
assert!(first.is_some());
assert!(coverage.lookups <= faces.len() * 3, "{} lookups for 3 characters", coverage.lookups);
assert_eq!(coverage.has(0, face, '日'), face_has(face.bytes, '日'));
}
/// A page's vocabulary: German umlauts are WinAnsi, and a character no bundled face has
/// (here an emoji) is counted wherever it appears.
#[test]
fn win_ansi_words_get_no_encoding_and_count_only_real_losses() {
let plan = TextPlan::for_words(["Zürcher", "café", "size"]);
assert!(plan.words.iter().all(Vec::is_empty), "all WinAnsi: no Type3 fonts needed");
assert!(plan.fonts.is_empty());
assert_eq!(plan.missing(), 0);
let none = TextPlan::for_words(["plain text"]);
assert_eq!(none.missing(), 0);
// U+1F600 has no WinAnsi code and no glyph in any bundled face (with craft-fonts the
// JIS faces even cover the snowman): counted, shown as '?'.
let emoji = TextPlan::for_words(["\u{1f600}!"]);
assert_eq!(emoji.missing(), 1);
}
#[test]
fn without_craft_fonts_every_non_win_ansi_character_is_reported() {
if !crate::CRAFT_FONTS.is_empty() {
eprintln!("built with craft-fonts: the planning tests with faces cover this instead");
return;
}
let plan = TextPlan::for_words(["Привет", "日本", "abc"]);
assert!(plan.words.iter().all(Vec::is_empty));
assert!(plan.fonts.is_empty());
assert_eq!(plan.missing(), 8, "six Cyrillic + two CJK characters, each reported");
}
#[test]
fn encodings_are_aligned_and_total_the_advances() {
if crate::CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
assert!(TextPlan::for_words(["日本"]).fonts.is_empty());
return;
}
let plan = TextPlan::for_words(["日本語", "語"]);
assert_eq!(plan.encoding(0).len(), 3);
assert_eq!(plan.encoding(1).len(), 1);
// 語 planned once keeps its code (and font) in both words.
let (f, c) = plan.encoding(1)[0];
assert_eq!(plan.encoding(0)[2], (f, c), "the repeated character keeps its code");
assert!(plan.advance(f, c) > 0.0);
assert_eq!(plan.advance(f, 0), 0.0, "codes start at 1");
assert_eq!(plan.encoding(7), &[], "out of range degrades to the WinAnsi path");
assert!(plan.advance_of(plan.encoding(0)) > plan.advance_of(plan.encoding(1)));
}
#[test]
fn hostile_input_stays_bounded() {
// Thousands of DISTINCT characters: the covered ones fill fonts up to the caps, the
// rest are reported substitutions.
let word: String = (0..4000u32).filter_map(|i| char::from_u32(0x4e00 + i)).collect();
let plan = TextPlan::for_words([word.as_str()]);
assert!(plan.planned_glyphs() <= MAX_PLAN_GLYPHS);
assert!(plan.fonts.iter().all(|f| f.glyphs.len() <= MAX_TYPE3_GLYPHS));
assert!(plan.missing() > 0, "characters beyond the caps are reported, never silently dropped");
}
#[test]
fn substitution_and_unknown_scripts_come_out_counted() {
if crate::CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
}
let plan = TextPlan::for_words(["日本\u{1f4a9}語"]);
// The emoji has no glyph in any bundled face; the kana/kanji do.
assert_eq!(plan.missing(), 1);
assert_eq!(plan.encoding(0).len(), 4, "the emoji still has an encoding: the font's ?");
}
}
+16 -1
View File
@@ -134,6 +134,17 @@ impl OutlinePen for Flatten {
}
}
/// Whether the face has a glyph for `ch` (a cheap character-map probe, no outline work).
pub fn face_has(bytes: &[u8], ch: char) -> bool {
FontRef::new(bytes).is_ok_and(|font| font.charmap().map(ch).is_some())
}
/// Return one glyph of `face` (a craft-fonts face), bounded so hostile replacement text cannot
/// allocate unbounded outline data.
pub fn craft_glyph(face: &crate::CraftFont, ch: char) -> Result<GlyphOutline, GlyphError> {
glyph_in_face(face.bytes, ch)
}
/// Return one glyph of the Japanese document face ([`crate::document_japanese_font`], Shippori
/// Mincho from craft-fonts), bounded so hostile replacement text cannot allocate unbounded
/// outline data. [`GlyphError::NoFont`] when the build has no Japanese face.
@@ -145,7 +156,11 @@ pub fn japanese_glyph(ch: char) -> Result<GlyphOutline, GlyphError> {
/// The bounded outline and advance from the explicitly selected craft-fonts face.
/// Uses the same complexity and missing-glyph checks as [`japanese_glyph`].
pub fn japanese_glyph_from(face: &crate::CraftFont, ch: char) -> Result<GlyphOutline, GlyphError> {
let Ok(font) = FontRef::new(face.bytes) else { return Err(GlyphError::Missing) };
glyph_in_face(face.bytes, ch)
}
fn glyph_in_face(bytes: &[u8], ch: char) -> Result<GlyphOutline, GlyphError> {
let Ok(font) = FontRef::new(bytes) else { return Err(GlyphError::Missing) };
let loc = LocationRef::default();
let metrics = font.metrics(Size::unscaled(), loc);
let scale = 1.0 / metrics.units_per_em.max(1) as f64;
+4
View File
@@ -16,5 +16,9 @@ ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }
rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }
thiserror.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
# The page image is handed to the external tesseract process as a PNG.
png = "0.18"
[dev-dependencies]
tempfile = "3.27"
+104
View File
@@ -0,0 +1,104 @@
# pdfcraft-ocr
Layer L4 (may use `fonts` for the text layers' metrics): Scan & OCR — recognising the words in
a page raster and turning them back into page content (execution plan M10).
```rust
// The caller renders a page to pixels and hands them over:
let image = OcrImage::new(w, h, rgba)?; // validated by construction
let lines = recognizer.recognize(&image, &options)?; // Vec<Line>: words in reading order
let placed: Vec<PlacedWord> = …; // PlacedWord::place maps boxes to user space
// The editable output draws real glyphs: plan the words, register the display faces into the
// plan (`pdfcraft_edit::add_type3_fonts`), then generate the layer. An unregistered plan falls
// back to WinAnsi, and characters no registered face has become '?'.
let plan = TextPlan::for_words(placed.iter().map(|w| w.text.as_str()));
add_type3_fonts(&mut doc, page, &mut plan)?; // names the plan's fonts on the page
let layer = visible_text_layer(&placed, &plan); // visible text: the editable output
// The searchable layer needs no display fonts: the engine's own layer (engine::ocr::layer)
// keeps every Unicode character with empty Type3 programs and a ToUnicode map beyond WinAnsi.
```
- **Engines behind one trait.** A [`Recognizer`] reports its `id`, whether it is `available`,
the languages it reads, and reads `Vec<Line>` from an image. Two exist: the pure-Rust
`OcrsRecognizer` (the `ocrs` crate, MIT/Apache-2.0, with its pre-trained models,
CC-BY-SA-4.0, fetched by `cargo xtask models`; found via `$PDFCRAFT_MODELS` or `models/`
beside the executable — see ATTRIBUTION.toml), and — off the web build —
`tesseract::TesseractCli`, which drives an installed `tesseract` binary as an external
process: never linked, bundled or downloaded. Implementations are shared across threads
(`Send + Sync`); nothing spawns on wasm32.
- **The tesseract process is fenced in.** The program comes from a user-configured location or
the PATH, never from document data (the engine validates a configured path as an absolute
path to an existing executable file before it is used). The language code is allow-listed
(`tesseract::language_code`, 12 codes) before anything reaches a process; the page image
goes to a private temp file (a fresh 0700 directory holding a 0600 file on Unix) that is
deleted afterwards; the run has a 120 s timeout, a 64 MiB output cap and a 50 000-word
reading cap (an incomplete reading is rejected rather than applied); every failure comes back through `OcrError` with its cause named. A configured
location that is invalid fails closed (it is never replaced by the PATH search). Availability
is probed with `--list-langs` (its list is read from stdout, or from stderr where older
versions print it); a failed probe is remembered for 30 s, so an install that appears later
is found without a restart. Known limit: at the timeout or the output cap only the direct
child is killed; a wrapper script's own children may outlive it.
- **Confidence drives review.** One classifier (`band_for`) puts a word in the High
(≥ 90), Medium (70–89) or Low (< 70) band — the review overlay's colours, the legend and the
counts; the batch flag rule (`low_confidence`, default threshold 60) is deliberately a
separate threshold. The `ocrs` models report no confidence (its words carry `None`);
a reading whose confidence is unknown is treated as unknown, never as good.
- **Two engines can be merged (ROVER).** Strategies: `PrimaryOnly` (the default), and two
ensembles — `ConfidenceWeighted` (the secondary engine joins when the primary's mean
confidence is under 70 or unknown; an empty primary keeps the fast path) and `RoverVote`
(both engines always run). The merge lets every primary word adopt the best-overlapping
unused secondary word at IoU ≥ 0.5; the higher confidence wins text, box and confidence (a
tie keeps the primary; against a primary word of unknown confidence — ocrs — the secondary
wins only at 70 or more, `UNKNOWN_CONFIDENCE_BAR`), the survivor is named `"ROVER"`, and
unclaimed secondary words are
appended under their engine's name. A merge is capped at 100 M word-pair comparisons (over
it, the primary reading stands alone), and a failing partner degrades to the primary with a
recorded note — a page is never skipped because a partner could not read it.
- **Preprocessing between the raster and recognition**, a pure function in fixed order: DPI
normalization to 300 (only when one of the steps below is on, since they are calibrated for
it; with all of them off the raster is read exactly as rendered, and a scan at or above 300
is left alone), then the opt-in steps —
orientation (90°/180°/270° from projection-profile banding), deskew (± 5°, shear-scored,
below 0.1° is a no-op), denoise (3×3 median) and binarization (Sauvola, window 8, k 0.34).
Every geometric step composes one exact affine `InverseTransform`, and callers map every
word box back to the original raster before placing it. The heuristics are conservative and
panic-free: too small to judge, blank, or no clear winner is a no-op, never a guess.
- **Searchable output preserves Unicode without display fonts.** The engine uses empty
Type3 character programs and `/ToUnicode` maps for non-WinAnsi words, leaving the scan
unchanged. WinAnsi words use `/PCHelv`; the `/OCR` layer is fitted to each word's box.
- **Editable output uses display glyphs.** `visible_text_layer` writes visible text whose
size comes from the box height and whose horizontal scaling uses real font advances.
Beyond WinAnsi, attributed craft-fonts faces supply outline Type3 glyphs with `/ToUnicode`.
Uncovered characters become `?` and are reported in editable output notes; searchable
output retains them. The engine refuses nonfinite geometry or oversized input before
planning fonts: 4 MiB of text and 65,536 words, across all pages for editable export.
Empty geometry is skipped; rotated words carry `across`/`up` vectors and retain direction.
- **Hostile input is rejected, not panicked at.** `OcrImage` holds private, validated state:
non-zero sides, a 10 000-pixel side cap, a 64-megapixel area cap (checked arithmetic — the
counts come from documents, so they are untrusted), and an exact buffer length.
## Where it is wired
The engine's `ocr` module renders pages into `OcrImage`s, runs jobs (`OcrJob::run`) and applies
the words: searchable-image mode draws the invisible layer over the page's own image and leaves
that image untouched; editable mode writes a NEW `<name>_ocr.pdf` of visible text (the scan
image is not carried over, the source is never overwritten). Skip probes keep pages — and
whole files — that already contain text out of the work unless Force OCR overrides them. A
searchable result applies as one undoable step; the Editable text output opens the OCR Verify
screen first, and Scan & OCR ▸ Correct recognized text reviews any recognition there before it
is applied (confidence-coloured boxes, corrections, a per-language user dictionary — a
plain-text file in the preferences folder, so words added from a document persist on disk
outside it until removed — Re-OCR of a region or a page, Accept/Reject), a
multiple-file run writes searchable copies as it goes, and the automation tools `ocr_recognize`,
`ocr_words`, `ocr_status` and `ocr_recognize_files` expose recognition headlessly.
`ocr_words` with `retain_review` returns a bounded session-local handle; `ocr_review_apply`
applies text corrections at the stored original boxes through its stale-document guard,
or discards the result. Empty corrections remove words. Searchable acceptance is one undo
step; editable acceptance writes a separate file. `ocr_dictionary` reads or atomically
updates the UI's per-language dictionary format through an explicitly root-confined path.
Not yet: languages beyond the tested set (the `ocrs` models read only unaccented Latin; the
Tesseract side is bounded by its allow-list and whatever its installed language packs cover),
OCR in the browser (the tesseract module is compiled out for wasm32), the deep scan-cleanup
options (descreen, background removal, edge-shadow removal), and editable glyph coverage
beyond the available attributed faces.
+106
View File
@@ -0,0 +1,106 @@
//! Confidence: one classifier ([`band_for`]) drives the review overlay, the legend, the counts
//! and the uncertain-word navigation; the batch rule ([`low_confidence`]) is a separate
//! threshold with its own range check. The two rules are deliberately never merged.
/// The three confidence bands (the review overlay's green / yellow / red).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum ConfidenceBand {
Low,
Medium,
High,
}
impl ConfidenceBand {
/// The legend line for this band, so the meaning never rests on colour alone.
pub fn legend(self) -> &'static str {
match self {
ConfidenceBand::High => "HIGH (>= 90%)",
ConfidenceBand::Medium => "MEDIUM (70-89%)",
ConfidenceBand::Low => "LOW (< 70%)",
}
}
}
/// The one classifier: `>= 90` is High, `70..=89` Medium, `< 70` Low. Out-of-range values
/// clamp (negative → Low, above 100 → High); NaN is Low.
pub fn band_for(confidence: f32) -> ConfidenceBand {
if confidence >= 90.0 {
ConfidenceBand::High
} else if confidence >= 70.0 {
ConfidenceBand::Medium
} else {
ConfidenceBand::Low
}
}
/// The batch rule, separate from [`band_for`]: a word is flagged when `0 <= conf <= threshold`
/// (default 60). Negative or above-threshold values are not flagged; NaN is not.
pub fn low_confidence(confidence: f32, threshold: f32) -> bool {
(0.0..=threshold).contains(&confidence)
}
/// A word whose confidence lands in the Low band — a suspect for review. A word without a
/// reported confidence has no signal and is never a suspect.
pub fn is_suspect(confidence: Option<f32>) -> bool {
confidence.is_some_and(|c| band_for(c) == ConfidenceBand::Low)
}
#[cfg(test)]
mod tests {
use super::*;
/// The boundary matrix: 89 and 90, 69 and 70 straddle the bands.
#[test]
fn bands_straddle_the_boundaries() {
assert_eq!(band_for(89.0), ConfidenceBand::Medium);
assert_eq!(band_for(90.0), ConfidenceBand::High);
assert_eq!(band_for(69.0), ConfidenceBand::Low);
assert_eq!(band_for(70.0), ConfidenceBand::Medium);
assert_eq!(band_for(0.0), ConfidenceBand::Low);
assert_eq!(band_for(100.0), ConfidenceBand::High);
}
/// Out-of-range values clamp, and NaN lands in the Low band instead of comparing equal to
/// nothing.
#[test]
fn out_of_range_confidences_clamp() {
assert_eq!(band_for(-0.5), ConfidenceBand::Low, "negative → Low");
assert_eq!(band_for(1000.0), ConfidenceBand::High, "above 100 → High");
assert_eq!(band_for(f32::NAN), ConfidenceBand::Low);
assert_eq!(band_for(f32::NEG_INFINITY), ConfidenceBand::Low);
assert_eq!(band_for(f32::INFINITY), ConfidenceBand::High);
}
/// The batch threshold is its own rule: everything in `0..=threshold` flags, including
/// words band_for calls Medium (60 < 70), and negatives never do.
#[test]
fn batch_threshold_is_separate_from_the_bands() {
assert!(low_confidence(60.0, 60.0), "at the threshold it flags");
assert!(low_confidence(0.0, 60.0));
assert!(!low_confidence(60.5, 60.0));
assert!(!low_confidence(-1.0, 60.0), "negative confidences are out of the rule's range");
assert!(!low_confidence(f32::NAN, 60.0));
// The rules disagree on real values: 65 is inside the Low band yet outside the batch
// rule with the default threshold (60), which is why the two must not be merged.
assert!(!low_confidence(65.0, 60.0));
assert_eq!(band_for(65.0), ConfidenceBand::Low);
}
/// Unconfident words are not suspects; Low-band words are.
#[test]
fn suspects_need_a_low_band_confidence() {
assert!(!is_suspect(None));
assert!(!is_suspect(Some(95.0)));
assert!(!is_suspect(Some(70.0)));
assert!(is_suspect(Some(69.9)));
assert!(is_suspect(Some(0.0)));
}
/// The legend names the thresholds, so the overlay never rests on colour alone.
#[test]
fn legend_text_matches_the_classifier() {
assert_eq!(ConfidenceBand::High.legend(), "HIGH (>= 90%)");
assert_eq!(ConfidenceBand::Medium.legend(), "MEDIUM (70-89%)");
assert_eq!(ConfidenceBand::Low.legend(), "LOW (< 70%)");
}
}
+603 -192
View File
@@ -1,35 +1,50 @@
//! pdfcraft-ocr — Scan & OCR ▸ Recognize text (L4).
//!
//! Recognition runs the ocrs engine (MIT/Apache-2.0) with its pre-trained models (CC-BY-SA-4.0,
//! fetched by `cargo xtask models`; see ATTRIBUTION.toml). The caller renders a page to pixels;
//! [`Ocr::recognize`] finds the words in them, and [`text_layer`] turns words placed in user space
//! into page content: invisible text (rendering mode 3) over each word, so the page becomes a
//! The caller renders a page to pixels ([`OcrImage`]) and hands it to a [`Recognizer`]. Two
//! engines exist: the pure-Rust `OcrsRecognizer` (ocrs, MIT/Apache-2.0, with its pre-trained
//! models, CC-BY-SA-4.0, fetched by `cargo xtask models`; see ATTRIBUTION.toml) and — off the
//! web — `tesseract::TesseractCli`, which drives an installed `tesseract` binary as an external
//! process. `merge` combines two engines' readings (ROVER), `preprocess` cleans the raster up
//! between rendering and recognition, and [`text_layer`] turns words placed in user space into
//! page content: invisible text (rendering mode 3) over each word, so the page becomes a
//! searchable image (Acrobat's "Searchable Image (Exact)": the image is left untouched).
//!
//! The models read the Latin alphabet (English and other languages written without accents).
//! Visible editable output uses [`pdfcraft_fonts::TextPlan`] and optional craft-fonts glyph
//! outlines; unsupported visible characters become `?` and are counted. The engine's invisible
//! searchable layer uses empty Type3 programs and ToUnicode maps, preserving Unicode without
//! display fonts. WinAnsi-only invisible text keeps the standard Helvetica layout here.
//!
//! The ocrs models read the Latin alphabet (English and other languages written without
//! accents); Tesseract (the `tesseract` module, off the web build) reads whatever its installed
//! language packs cover.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
use std::path::{Path, PathBuf};
pub mod confidence;
pub mod merge;
mod ocrs;
pub mod preprocess;
#[cfg(not(target_arch = "wasm32"))]
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 pdfcraft_fonts::TextPlan;
pub use pdfcraft_fonts::helvetica_width;
/// The model files, as named in ATTRIBUTION.toml.
pub const DETECTION_MODEL: &str = "text-detection.onnx";
pub const RECOGNITION_MODEL: &str = "text-recognition.onnx";
/// Hard cap on either side of an image any code in this crate will touch, in pixels; larger
/// images are rejected with [`OcrError::ImageTooLarge`] instead of being processed.
pub const MAX_SIDE: u32 = 10_000;
/// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its
/// output classes follow this order. The ocrs crate's own copy has an `E` where the training had
/// `€`, which made a scanned "€250" read as "E250".
pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/// The languages the models read (ISO 639-1); all use the Latin alphabet without accents.
pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")];
/// Hard cap on an image's TOTAL area, in pixels (64 MP): sides under [`MAX_SIDE`] can still
/// multiply out to a raster no engine should have to read; larger images are rejected with
/// [`OcrError::ImageTooLarge`] instead of being processed.
pub const MAX_PIXELS: u32 = 64_000_000;
#[derive(Debug, thiserror::Error)]
pub enum OcrError {
/// Shown to end users (release packages ship the models; a source build fetches them with
/// `cargo xtask models`). The UI catalogs key this exact text.
#[error("the text recognition models are not installed (reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them)")]
NoModels,
#[error("loading {0}: {1}")]
@@ -38,52 +53,93 @@ pub enum OcrError {
Recognize(String),
#[error("the image is empty")]
EmptyImage,
#[error("the image is larger than the {MAX_SIDE}-pixel side or {MAX_PIXELS}-pixel limit")]
ImageTooLarge,
#[error("the image buffer holds {got} bytes, expected {want} for {width}x{height} RGBA")]
ImageSize { got: usize, want: usize, width: u32, height: u32 },
#[error("the tesseract program is not available: {0}")]
NoTesseract(String),
#[error("tesseract did not finish within {0} seconds")]
Timeout(u64),
#[error("tesseract produced more than {0} bytes of output")]
OutputTooLarge(u64),
#[error("tesseract failed: {0}")]
Process(String),
}
/// Where the two model files are.
#[derive(Clone, Debug, PartialEq)]
pub struct Models {
pub detection: PathBuf,
pub recognition: PathBuf,
/// An RGBA8 raster, row-major, top-left origin: what the engines read. The fields are private
/// so the invariants (non-zero, capped, exactly `width * height * 4` bytes) hold by
/// construction: an [`OcrImage`] in hand has always been validated, whatever produced it.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OcrImage {
width: u32,
height: u32,
rgba: Vec<u8>,
}
impl Models {
/// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files.
/// An incomplete update never mixes formats or hides an intact legacy pair.
pub fn in_dir(dir: &Path) -> Option<Models> {
[(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| {
let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) };
(m.detection.is_file() && m.recognition.is_file()).then_some(m)
})
}
/// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them
/// beside the executable ([`Models::dirs_beside_exe`]), then the source tree's `assets/models/`.
pub fn find() -> Option<Models> {
Self::search_dirs().iter().find_map(|d| Self::in_dir(d))
}
/// The directories [`Models::find`] looks in, in order.
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(d) = std::env::var_os("PDFCRAFT_MODELS") {
dirs.push(PathBuf::from(d));
impl OcrImage {
/// Validate the sides ([`MAX_SIDE`]) and total area ([`MAX_PIXELS`]) and that `rgba` holds
/// exactly `width * height * 4` bytes (checked arithmetic; the counts come from documents,
/// so they are untrusted).
pub fn new(width: u32, height: u32, rgba: Vec<u8>) -> Result<OcrImage, OcrError> {
if width == 0 || height == 0 {
return Err(OcrError::EmptyImage);
}
if let Some(exe) = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf)) {
dirs.extend(Self::dirs_beside_exe(&exe));
if width > MAX_SIDE || height > MAX_SIDE {
return Err(OcrError::ImageTooLarge);
}
let pixels = (width as usize).checked_mul(height as usize).ok_or(OcrError::ImageTooLarge)?;
if pixels > MAX_PIXELS as usize {
return Err(OcrError::ImageTooLarge);
}
match pixels.checked_mul(4) {
Some(want) if rgba.len() == want => Ok(OcrImage { width, height, rgba }),
Some(want) => Err(OcrError::ImageSize { got: rgba.len(), want, width, height }),
None => Err(OcrError::ImageTooLarge),
}
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models"));
dirs
}
/// Where the release packages put the models, relative to the directory holding the
/// executable (packaging/*):
/// - `models/` beside it: the Windows MSI and portable zip;
/// - `../Resources/models`: the macOS app bundle (`Contents/MacOS` → `Contents/Resources`);
/// - `../share/pdfcraft/models`: the FHS-style trees (`bin/` → `share/`) of the Linux
/// deb/rpm/tar.gz/AppImage/Flatpak and the FreeBSD tarball, wherever they are installed.
pub fn dirs_beside_exe(exe_dir: &Path) -> Vec<PathBuf> {
vec![exe_dir.join("models"), exe_dir.join("../Resources/models"), exe_dir.join("../share/pdfcraft/models")]
/// The image's width, in pixels.
pub fn width(&self) -> u32 {
self.width
}
/// The image's height, in pixels.
pub fn height(&self) -> u32 {
self.height
}
/// The RGBA bytes, row-major, exactly `width * height * 4` of them.
pub fn rgba(&self) -> &[u8] {
&self.rgba
}
/// The smallest side, in pixels.
pub fn min_side(&self) -> u32 {
self.width.min(self.height)
}
/// One RGBA byte at integer pixel (`x`, `y`), edges clamped; 255 (white) outside. Only
/// whole-pixel positions and channel 0–3 make sense; anything else reads clamped.
pub fn byte(&self, x: f64, y: f64, ch: usize) -> u8 {
let stride = self.width as usize * 4;
if stride == 0 || self.height == 0 || ch > 3 {
return 255;
}
let xi = (x.round().max(0.0) as usize).min(self.width as usize - 1);
let yi = (y.round().max(0.0) as usize).min(self.height as usize - 1);
self.rgba.get(yi * stride + xi * 4 + ch).copied().unwrap_or(255)
}
/// The grayscale value of the pixel at (`x`, `y`), 0 (ink) … 255 (paper), with the borders
/// clamped (edges replicate). `None` only when the image is empty.
pub fn gray(&self, x: i64, y: i64) -> Option<u8> {
let x = x.clamp(0, self.width as i64 - 1);
let y = y.clamp(0, self.height as i64 - 1);
let row = y as usize * self.width as usize * 4;
let o = row + x as usize * 4;
let px = self.rgba.get(o..o + 4)?;
Some(((299 * px[0] as u32 + 587 * px[1] as u32 + 114 * px[2] as u32) / 1000) as u8)
}
}
@@ -92,6 +148,26 @@ impl Models {
pub struct Word {
pub text: String,
pub rect: [f32; 4],
/// How sure the engine is, 0–100, when it reports a confidence at all (`None`: the ocrs
/// engine does not).
pub confidence: Option<f32>,
/// Which engine read this word: the recogniser's [`Recognizer::id`], or `"ROVER"` when two
/// engines were merged and the surviving text is the merged choice.
pub source: String,
}
impl Word {
pub fn new(text: impl Into<String>, rect: [f32; 4], source: &str) -> Word {
Word { text: text.into(), rect, confidence: None, source: source.into() }
}
/// Set the confidence (builder style), clamped to the documented 0–100 scale (a negative
/// value is 0, anything over 100 is 100). NaN means no confidence was reported (`None`),
/// the same unknown the engines that report nothing carry.
pub fn with_confidence(mut self, confidence: f32) -> Word {
self.confidence = if confidence.is_nan() { None } else { Some(confidence.clamp(0.0, 100.0)) };
self
}
}
/// A recognised line of words, in reading order.
@@ -106,63 +182,34 @@ impl Line {
}
}
/// A loaded recogniser. Loading takes a moment; keep one and reuse it.
pub struct Ocr {
engine: ocrs::OcrEngine,
/// What a recogniser needs to know beyond the pixels.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecognizeOptions {
/// Language code from [`LANGUAGES`] (`"en"`, `"de"`, …). Engines map it to their own codes
/// (Tesseract: `tesseract::language_code`, allow-listed before anything reaches a process);
/// unknown codes fall back to the engine's default.
pub language: String,
}
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()));
let params = ocrs::OcrEngineParams {
detection_model: Some(load(&models.detection)?),
recognition_model: Some(load(&models.recognition)?),
alphabet: Some(ALPHABET.into()),
..Default::default()
};
let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?;
Ok(Ocr { engine })
impl Default for RecognizeOptions {
fn default() -> Self {
RecognizeOptions { language: "en".into() }
}
}
/// Load the models found by [`Models::find`].
pub fn find() -> Result<Ocr, OcrError> {
Self::load(&Models::find().ok_or(OcrError::NoModels)?)
}
/// Recognise the text in an RGBA (or RGB, or grey) image, `width` × `height` pixels.
pub fn recognize(&self, pixels: &[u8], width: u32, height: u32) -> Result<Vec<Line>, OcrError> {
if width == 0 || height == 0 || pixels.is_empty() {
return Err(OcrError::EmptyImage);
}
// ocrs wants 1 or 3 channels.
let rgb: std::borrow::Cow<[u8]> = if pixels.len() == (width * height * 4) as usize {
pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::<Vec<u8>>().into()
} else {
pixels.into()
};
let err = |e: &dyn std::fmt::Display| OcrError::Recognize(e.to_string());
let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?;
let input = self.engine.prepare_input(source).map_err(|e| err(&e))?;
let found = self.engine.detect_words(&input).map_err(|e| err(&e))?;
let lines = self.engine.find_text_lines(&input, &found);
let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?;
use ocrs::TextItem;
Ok(read
.into_iter()
.flatten()
.map(|line| Line {
words: line
.words()
.map(|w| {
let r = w.bounding_rect();
Word { text: w.to_string(), rect: [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32] }
})
.filter(|w| !w.text.trim().is_empty())
.collect(),
})
.filter(|l| !l.words.is_empty())
.collect())
}
/// One text-recognition engine. Implementations are shared across threads
/// (`Arc<dyn Recognizer>`); `available` and `recognize` may spawn processes only off the web
/// build (see the `tesseract` module).
pub trait Recognizer: Send + Sync {
/// The engine's short name (`"ocrs"`, `"tesseract"`): what [`Word::source`] carries and
/// what settings and tools name it by.
fn id(&self) -> &'static str;
/// Whether this engine can run right now (models installed, binary found).
fn available(&self) -> bool;
/// The languages it reads, as `(code, name)` pairs (`"en"`, `"English"`).
fn languages(&self) -> Vec<(String, String)>;
/// Read the words in `image`, in reading order.
fn recognize(&self, image: &OcrImage, options: &RecognizeOptions) -> Result<Vec<Line>, OcrError>;
}
/// A word placed on a page, in PDF user space: the bottom-left corner of its box, and the
@@ -173,6 +220,10 @@ pub struct PlacedWord {
pub origin: [f64; 2],
pub across: [f64; 2],
pub up: [f64; 2],
/// Confidence 0–100, when the engine reported one.
pub confidence: Option<f32>,
/// Which engine read this word (see [`Word::source`]).
pub source: String,
}
impl PlacedWord {
@@ -183,7 +234,14 @@ impl PlacedWord {
let o = to_user(l, b);
let br = to_user(r, b);
let tl = to_user(l, t);
PlacedWord { text: word.text.clone(), origin: o, across: [br[0] - o[0], br[1] - o[1]], up: [tl[0] - o[0], tl[1] - o[1]] }
PlacedWord {
text: word.text.clone(),
origin: o,
across: [br[0] - o[0], br[1] - o[1]],
up: [tl[0] - o[0], tl[1] - o[1]],
confidence: word.confidence,
source: word.source.clone(),
}
}
}
@@ -214,32 +272,193 @@ fn num(v: f64) -> String {
if s == "-0" { "0".into() } else { s.into() }
}
/// Page content that writes `words` as invisible text in `/PCHelv` (standard Helvetica,
/// WinAnsiEncoding), each word stretched to its box so selection and search highlight the
/// right place. Marked content `/OCR` so it can be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord]) -> Vec<u8> {
/// One run of a word's planned characters in one Type3 font: the resource name, the codes to
/// show (one byte per character), and the run's start as an offset in em units from the word's
/// origin (the accumulated advances of the runs before it).
struct PlannedRun {
font: String,
codes: Vec<u8>,
start: f64,
}
/// The planned runs of word `i` (the same index [`pdfcraft_fonts::TextPlan::for_words`] planned
/// the words with), or `None` when the word is drawn in Helvetica: every character has a
/// WinAnsi code, or nothing could be planned/registered for it (the pre-Unicode output).
fn planned_runs(plan: &pdfcraft_fonts::TextPlan, word: usize) -> Option<(Vec<PlannedRun>, f64)> {
let enc = plan.encoding(word);
if enc.is_empty() || !plan.registered(enc) {
return None;
}
let mut runs: Vec<PlannedRun> = Vec::new();
let mut total = 0.0;
for &(f, code) in enc {
let font = plan.fonts[f].name.as_str();
match runs.last_mut() {
Some(last) if last.font == font => last.codes.push(code),
_ => runs.push(PlannedRun { font: font.to_string(), codes: vec![code], start: total }),
}
total += plan.advance(f, code);
}
(total.is_finite() && total > 0.0).then_some((runs, total))
}
/// Page content that writes `words` as VISIBLE text at their original boxes: the
/// editable-text output's page content. WinAnsi words are set in `/PCHelv` (standard
/// Helvetica); words with characters beyond WinAnsi are set in the plan's Type3 fonts, one run
/// per font. Each word fills its box — the font size comes from the box height and the
/// horizontal scaling is computed from the real advances of the font that draws the word
/// (Helvetica's approximated, the Type3 faces' exact) and emitted as an explicit `Tz`
/// percentage. Words whose box is empty (or whose text has no measurable width) are skipped; a
/// page that ends up with nothing simply shows nothing.
pub fn visible_text_layer(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan) -> Vec<u8> {
visible_text_layer_at(words, plan, 0)
}
/// Visible text for words starting at `word_offset` in a shared document plan.
pub fn visible_text_layer_at(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan, word_offset: usize) -> Vec<u8> {
// The rendering mode and the font are graphics state, which `ET` doesn't reset: without
// `q … Q` they would stay set for every later content stream on the page, so text added
// after OCR (a header or footer) drew invisibly (#3029).
let mut out = b"/OCR BMC\nq\nBT\n0 Tr\n/PCHelv 1 Tf\n".to_vec();
// The font and size the text state is set in, so a Tf is written only when they change.
// (The header's size-1 Helvetica covers layers of WinAnsi words only.)
let mut current: Option<(String, f64)> = Some(("PCHelv".to_string(), 1.0));
for (i, w) in words.iter().enumerate() {
let height = w.up[0].hypot(w.up[1]);
let across = w.across[0].hypot(w.across[1]);
let (ux, uy) = (w.across[0] / across, w.across[1] / across);
let (vx, vy) = (w.up[0] / height, w.up[1] / height);
if !across.is_finite()
|| !height.is_finite()
|| across <= 0.0
|| height <= 0.0
|| !ux.is_finite()
|| !vx.is_finite()
|| !w.origin.iter().all(|v| v.is_finite())
{
continue;
}
let size = height / BOX_EM;
let runs = planned_runs(plan, word_offset.saturating_add(i));
// The word's natural advance at this size: Helvetica's approximation, or the exact sum
// of the planned glyphs' widths.
let natural = runs.as_ref().map_or_else(|| helvetica_width(&w.text, size), |(_, total)| total * size);
if natural.is_nan() || natural <= 0.0 {
continue;
}
// Horizontal scaling as a percentage of the natural advance at this size, so the word
// lands exactly on its box (100 = no scaling; a long box on a short word stretches it).
let tz = (across / natural * 100.0).max(1.0);
// The baseline sits above the box's bottom edge by the descender depth.
let o = [w.origin[0] + vx * DESCENT * size, w.origin[1] + vy * DESCENT * size];
out.extend_from_slice(format!("{} Tz ", num(tz)).as_bytes());
match &runs {
None => {
if current.as_ref().is_some_and(|(f, s)| f != "PCHelv" || *s != size) {
out.extend_from_slice(format!("/PCHelv {} Tf ", num(size)).as_bytes());
current = Some(("PCHelv".to_string(), size));
}
out.extend_from_slice(format!("{} {} {} {} {} {} Tm ", num(ux), num(uy), num(vx), num(vy), num(o[0]), num(o[1])).as_bytes());
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
Some((planned, total)) => {
for run in planned {
if current.as_ref().is_some_and(|(f, s)| *f != run.font || *s != size) {
out.extend_from_slice(format!("/{} {} Tf ", run.font, num(size)).as_bytes());
current = Some((run.font.clone(), size));
}
// The run's origin: the word's baseline plus the across vector scaled by
// the advances before this run (the stretch applies to runs alike).
let frac = run.start / total;
out.extend_from_slice(
format!(
"{} {} {} {} {} {} Tm ",
num(ux),
num(uy),
num(vx),
num(vy),
num(o[0] + w.across[0] * frac),
num(o[1] + w.across[1] * frac)
)
.as_bytes(),
);
out.extend(pdfcraft_fonts::literal(&run.codes));
out.extend_from_slice(b" Tj\n");
}
}
}
}
out.extend_from_slice(b"ET\nQ\nEMC\n");
out
}
/// Page content that writes `words` as invisible text (rendering mode 3), each word stretched
/// to its box so selection and search highlight the right place. WinAnsi words are set in
/// `/PCHelv` (standard Helvetica); words with characters beyond WinAnsi are set in the plan's
/// Type3 fonts, one run per font, with the same exact box fit. Marked content `/OCR` so it can
/// be told apart from the page's own text.
pub fn text_layer(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan) -> Vec<u8> {
// `3 Tr` and the font are graphics state, which `ET` doesn't reset: without `q … Q` they
// would stay set for every later content stream on the page, so text added after OCR (a
// header or footer) drew invisibly (#3029).
let mut out = b"/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\n".to_vec();
for w in words {
let width = helvetica_width(&w.text, 1.0);
let mut current: Option<(String, f64)> = Some(("PCHelv".to_string(), 1.0));
for (i, w) in words.iter().enumerate() {
let runs = planned_runs(plan, i);
let width = runs.as_ref().map_or_else(|| helvetica_width(&w.text, 1.0), |(_, total)| *total);
let height = w.up[0].hypot(w.up[1]);
if width <= 0.0 || height <= 0.0 {
let across = w.across[0].hypot(w.across[1]);
// Only positive, finite geometry reaches the content stream: a zero-sized box cannot
// be drawn, and a NaN or infinite one would put non-finite numbers on the page. (NaN
// fails every comparison, so the positivity checks already refuse it; the finiteness
// checks keep that property explicit.)
if width <= 0.0 || !(height > 0.0 && height.is_finite()) || !(across > 0.0 && across.is_finite()) || !w.origin.iter().all(|v| v.is_finite()) {
continue;
}
// The em square: the word's own ink (cap height or x-height, descenders or not) spans the
// box, so the text is as tall as the scanned word; its width is stretched to fit the word.
let (top, bottom) = ink_extent(&w.text);
// The em square. Helvetica words: the word's own ink (cap height or x-height,
// descenders or not) spans the box, so the text is as tall as the scanned word. Planned
// (Type3) words: those Helvetica letter heights do not describe the face, so the box spans
// the ascender-descender height. Either way the width is stretched to fit the word.
let (top, bottom) = if runs.is_some() { (BOX_EM - DESCENT, -DESCENT) } else { ink_extent(&w.text) };
let em = [w.up[0] / (top - bottom), w.up[1] / (top - bottom)];
let ax = [w.across[0] / width, w.across[1] / width];
let o = [w.origin[0] - em[0] * bottom, w.origin[1] - em[1] * bottom];
let m = [ax[0], ax[1], em[0], em[1], o[0], o[1]];
let nums: Vec<String> = m.iter().map(|v| num(*v)).collect();
out.extend_from_slice(nums.join(" ").as_bytes());
out.extend_from_slice(b" Tm ");
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
match &runs {
None => {
let ax = [w.across[0] / width, w.across[1] / width];
if current.as_ref().is_some_and(|(f, s)| f != "PCHelv" || *s != 1.0) {
out.extend_from_slice(b"/PCHelv 1 Tf ");
current = Some(("PCHelv".to_string(), 1.0));
}
let m = [ax[0], ax[1], em[0], em[1], o[0], o[1]];
let nums: Vec<String> = m.iter().map(|v| num(*v)).collect();
out.extend_from_slice(nums.join(" ").as_bytes());
out.extend_from_slice(b" Tm ");
out.extend(pdfcraft_fonts::literal(&pdfcraft_fonts::win_ansi(&w.text)));
out.extend_from_slice(b" Tj\n");
}
Some((planned, total)) => {
for run in planned {
if current.as_ref().is_some_and(|(f, s)| *f != run.font || *s != 1.0) {
out.extend_from_slice(format!("/{} 1 Tf ", run.font).as_bytes());
current = Some((run.font.clone(), 1.0));
}
let m = [
w.across[0] / total,
w.across[1] / total,
em[0],
em[1],
o[0] + w.across[0] * run.start / total,
o[1] + w.across[1] * run.start / total,
];
let nums: Vec<String> = m.iter().map(|v| num(*v)).collect();
out.extend_from_slice(nums.join(" ").as_bytes());
out.extend_from_slice(b" Tm ");
out.extend(pdfcraft_fonts::literal(&run.codes));
out.extend_from_slice(b" Tj\n");
}
}
}
}
out.extend_from_slice(b"ET\nQ\nEMC\n");
out
@@ -253,10 +472,22 @@ mod tests {
fn places_words_through_the_mapping() {
// 2 pixels per point, page 612 × 792, y flipped.
let to_user = |x: f32, y: f32| [x as f64 / 2.0, 792.0 - y as f64 / 2.0];
let w = PlacedWord::place(&Word { text: "Hello".into(), rect: [100.0, 200.0, 300.0, 240.0] }, to_user);
let w = PlacedWord::place(&Word::new("Hello", [100.0, 200.0, 300.0, 240.0], "ocrs"), to_user);
assert_eq!(w.origin, [50.0, 672.0]);
assert_eq!(w.across, [100.0, 0.0]);
assert_eq!(w.up, [0.0, 20.0]);
assert_eq!(w.source, "ocrs");
assert_eq!(w.confidence, None);
}
/// Confidence and source survive placement.
#[test]
fn placed_words_carry_confidence_and_source() {
let to_user = |x: f32, y: f32| [x as f64, y as f64];
let w = Word::new("Hi", [1.0, 2.0, 3.0, 4.0], "tesseract").with_confidence(87.5);
let p = PlacedWord::place(&w, to_user);
assert_eq!(p.confidence, Some(87.5));
assert_eq!(p.source, "tesseract");
}
/// The text layer is Helvetica in WinAnsiEncoding: every character the model can recognise
@@ -267,10 +498,27 @@ mod tests {
assert_eq!(pdfcraft_fonts::first_undrawable(ALPHABET), None);
}
/// `with_confidence` is clamped to the documented 0–100 scale: negatives (and −inf) are 0,
/// anything over 100 (and +inf) is 100, in-scale values pass through, and NaN means no
/// confidence was reported — the same `None` unknown the unreporting engines carry.
#[test]
fn with_confidence_clamps_and_treats_nan_as_unknown() {
let c = |v: f32| Word::new("w", [0.0; 4], "ocrs").with_confidence(v).confidence;
assert_eq!(c(0.0), Some(0.0));
assert_eq!(c(50.0), Some(50.0));
assert_eq!(c(100.0), Some(100.0));
assert_eq!(c(-1.0), Some(0.0), "negatives clamp to 0");
assert_eq!(c(f32::NEG_INFINITY), Some(0.0));
assert_eq!(c(150.0), Some(100.0), "over 100 clamps to 100");
assert_eq!(c(f32::INFINITY), Some(100.0));
assert_eq!(c(f32::NAN), None, "NaN is unknown, not a confidence");
assert_eq!(c(f32::EPSILON), Some(f32::EPSILON), "in-scale values are untouched");
}
#[test]
fn text_layer_is_invisible_text_fitted_to_each_box() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3], confidence: None, source: "ocrs".into() };
let s = String::from_utf8(text_layer(&[w], &TextPlan::default())).unwrap();
assert!(s.contains("3 Tr") && s.contains("/PCHelv 1 Tf"), "{s}");
let width = helvetica_width("Hi", 1.0);
// "Hi" has no descender: its box spans the cap height, from the baseline up.
@@ -283,8 +531,8 @@ mod tests {
/// after it on the page starts from the graphics state it had before.
#[test]
fn the_layer_restores_the_graphics_state_it_changes() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [30.0, 0.0], up: [0.0, 9.3], confidence: None, source: "ocrs".into() };
let s = String::from_utf8(text_layer(&[w], &TextPlan::default())).unwrap();
let mut depth = 0_i32;
for token in s.split_ascii_whitespace() {
match token {
@@ -309,78 +557,200 @@ mod tests {
assert_eq!(ink_extent("Gallus.Ze@camtel.cm"), (CAP_HEIGHT, 0.0));
assert_eq!(ink_extent("Corporation"), (CAP_HEIGHT, -DESCENT));
assert_eq!(ink_extent("--"), (BOX_EM - DESCENT, -DESCENT));
let w = PlacedWord { text: "gap".into(), origin: [0.0, 100.0], across: [20.0, 0.0], up: [0.0, 7.3] };
let s = String::from_utf8(text_layer(&[w])).unwrap();
let w = PlacedWord { text: "gap".into(), origin: [0.0, 100.0], across: [20.0, 0.0], up: [0.0, 7.3], confidence: None, source: "ocrs".into() };
let s = String::from_utf8(text_layer(&[w], &TextPlan::default())).unwrap();
let em = 7.3 / (X_HEIGHT + DESCENT);
assert!(s.contains(&format!(" 0 0 {} 0 {} Tm (gap) Tj", num(em), num(100.0 + em * DESCENT))), "{s}");
}
/// A word whose box is not positive, finite geometry never reaches the content stream:
/// NaN, infinite and zero-sized boxes are dropped, so the layer never carries non-finite
/// numbers.
#[test]
fn text_layer_refuses_non_finite_and_degenerate_boxes() {
let word = |across: [f64; 2], up: [f64; 2], origin: [f64; 2]| PlacedWord {
text: "Hi".into(),
origin,
across,
up,
confidence: Some(90.0),
source: "tesseract".into(),
};
for (why, w) in [
("NaN across", word([f64::NAN, 0.0], [0.0, 9.3], [0.0; 2])),
("NaN up", word([30.0, 0.0], [f64::NAN, 1.0], [0.0; 2])),
("inf up", word([30.0, 0.0], [0.0, f64::INFINITY], [0.0; 2])),
("inf origin", word([30.0, 0.0], [0.0, 9.3], [0.0, f64::INFINITY])),
("zero across", word([0.0, 0.0], [0.0, 9.3], [0.0; 2])),
("NaN origin", word([30.0, 0.0], [0.0, 9.3], [0.0, f64::NAN])),
] {
let s = String::from_utf8(text_layer(&[w], &TextPlan::default())).unwrap();
assert_eq!(s, "/OCR BMC\nq\nBT\n3 Tr\n/PCHelv 1 Tf\nET\nQ\nEMC\n", "{why}: {s}");
assert!(!s.contains("NaN") && !s.contains("inf"), "{why}: {s}");
}
}
/// The editable-text layer draws visible text whose horizontal scaling is an explicit
/// percentage computed from real Helvetica metrics: `Tz` × natural advance = box width.
#[test]
fn visible_text_layer_scales_by_a_percentage_of_real_metrics() {
let w = PlacedWord { text: "Hi".into(), origin: [10.0, 20.0], across: [40.0, 0.0], up: [0.0, 9.3], confidence: None, source: "ocrs".into() };
let s = String::from_utf8(visible_text_layer(&[w], &TextPlan::default())).unwrap();
assert!(s.contains("0 Tr"), "visible, not invisible: {s}");
let size = 9.3 / BOX_EM;
let natural = helvetica_width("Hi", size);
let tz = 40.0 / natural * 100.0;
assert!(s.contains(&format!("{} Tz", num(tz))), "{s}");
assert!(s.contains("(Hi) Tj"), "{s}");
assert!(s.starts_with("/OCR BMC") && s.ends_with("EMC\n"));
}
/// Degenerate boxes and words with no measurable width are skipped, never divide by zero.
#[test]
fn visible_text_layer_skips_degenerate_words() {
let word = |across: [f64; 2], up: [f64; 2], text: &str| PlacedWord {
text: text.into(),
origin: [0.0; 2],
across,
up,
confidence: None,
source: "ocrs".into(),
};
let words = vec![
word([0.0, 0.0], [0.0, 9.0], "flat"),
word([10.0, 0.0], [0.0, 0.0], "short"),
word([f64::NAN, 0.0], [0.0, 9.0], "nan"),
word([10.0, 0.0], [0.0, 9.0], ""),
word([10.0, 0.0], [0.0, 9.0], "Hi"),
];
let s = String::from_utf8(visible_text_layer(&words, &TextPlan::default())).unwrap();
assert_eq!(s.matches("Tj").count(), 1, "only the one drawable word is written: {s}");
}
/// A word whose ORIGIN is not finite never reaches the visible layer either: a NaN or
/// infinite origin would put "NaN"/"inf" into the content stream.
#[test]
fn visible_text_layer_refuses_non_finite_origins() {
let word =
|origin: [f64; 2]| PlacedWord { text: "Hi".into(), origin, across: [10.0, 0.0], up: [0.0, 9.0], confidence: None, source: "ocrs".into() };
for (why, origin) in [("NaN x", [f64::NAN, 100.0]), ("inf y", [10.0, f64::INFINITY]), ("NaN both", [f64::NAN; 2])] {
let s = String::from_utf8(visible_text_layer(&[word(origin)], &TextPlan::default())).unwrap();
assert!(!s.contains("NaN") && !s.contains("inf"), "{why}: {s}");
assert_eq!(s.matches("Tj").count(), 0, "{why}: {s}");
}
}
/// After a Type3 word the text state goes back to Helvetica: the Latin words around a
/// planned word must not inherit its font.
#[test]
fn helvetica_is_restored_after_a_planned_word() {
if pdfcraft_fonts::CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
}
let word = |text: &str| PlacedWord {
text: text.into(),
origin: [10.0, 20.0],
across: [60.0, 0.0],
up: [0.0, 9.3],
confidence: None,
source: "ocrs".into(),
};
let words = [word("Hi"), word("日本"), word("Ho")];
let mut plan = TextPlan::for_words(["Hi", "日本", "Ho"]);
for (i, font) in plan.fonts.iter_mut().enumerate() {
font.name = format!("PCOcr{i}");
}
let s = String::from_utf8(text_layer(&words, &plan)).unwrap();
assert!(s.contains("/PCOcr0 1 Tf"), "{s}");
// The last Helvetica selection comes after the Type3 run, and the Latin word after it
// is drawn in Helvetica, not left in the planned font.
assert!(s.rfind("/PCOcr0 1 Tf").unwrap_or(0) < s.rfind("/PCHelv 1 Tf").unwrap_or(usize::MAX), "{s}");
// The `Q` closes the `q … Q` scope that keeps `3 Tr` from leaking into later content.
assert!(s.ends_with("Tm (Ho) Tj\nET\nQ\nEMC\n"), "{s}");
// The visible layer restores Helvetica with the word's size.
let v = String::from_utf8(visible_text_layer(&words, &plan)).unwrap();
assert!(v.rfind("/PCOcr0").unwrap_or(0) < v.rfind("/PCHelv").unwrap_or(usize::MAX), "{v}");
assert!(v.contains("(Ho) Tj"), "{v}");
}
/// Words with characters beyond WinAnsi draw in the plan's Type3 font, one run, with the
/// same exact box fit as the Helvetica words (the matrix x-axis spans across / advance).
#[test]
fn planned_words_draw_in_their_font_with_the_same_exact_fit() {
if pdfcraft_fonts::CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
}
let w =
PlacedWord { text: "日本".into(), origin: [10.0, 20.0], across: [60.0, 0.0], up: [0.0, 9.3], confidence: None, source: "ocrs".into() };
let mut plan = TextPlan::for_words(["日本"]);
// The names the registrar (pdfcraft_edit::add_type3_fonts) fills in — this crate
// cannot reach it, so the test sets them where it reads them.
for (i, font) in plan.fonts.iter_mut().enumerate() {
font.name = format!("PCOcr{i}");
}
let codes: Vec<u8> = plan.encoding(0).iter().map(|&(_, c)| c).collect();
assert_eq!(codes.len(), 2, "{codes:?}");
let total = plan.advance_of(plan.encoding(0));
let s = String::from_utf8(text_layer(std::slice::from_ref(&w), &plan)).unwrap();
assert!(s.contains("/PCOcr0 1 Tf"), "{s}");
assert!(s.contains(&format!("{} 0 0 10 10 22.1 Tm ", num(60.0 / total))), "{s}");
assert!(s.contains(&format!("Tm {} Tj\n", String::from_utf8_lossy(&pdfcraft_fonts::literal(&codes)))), "{s}");
// The visible layer: size from the box height, Tz from the Type3's own advance.
let v = String::from_utf8(visible_text_layer(std::slice::from_ref(&w), &plan)).unwrap();
let size = 9.3 / BOX_EM;
assert!(v.contains(&format!("/PCOcr0 {} Tf ", num(size))), "{v}");
assert!(v.contains(&format!("{} Tz", num(60.0 / (total * size) * 100.0))), "{v}");
}
/// Without planned characters (an empty plan: built without craft-fonts, or before the
/// fonts are registered) the WinAnsi path stands, '?' included.
#[test]
fn unplanned_words_fall_back_to_win_ansi_question_marks() {
let w =
PlacedWord { text: "日本".into(), origin: [10.0, 20.0], across: [60.0, 0.0], up: [0.0, 9.3], confidence: None, source: "ocrs".into() };
let s = String::from_utf8(text_layer(&[w], &TextPlan::default())).unwrap();
assert!(s.contains("(??) Tj"), "{s}");
assert!(!s.contains("PCOcr"), "{s}");
}
/// A character no face has a glyph for becomes the font's '?', still in the run, counted
/// by the plan.
#[test]
fn substitutions_are_written_as_the_font_s_question_mark() {
if pdfcraft_fonts::CRAFT_FONTS.is_empty() {
eprintln!("skipping: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
return;
}
let w = PlacedWord {
text: "日\u{1f4a9}語".into(),
origin: [0.0, 0.0],
across: [60.0, 0.0],
up: [0.0, 9.3],
confidence: None,
source: "ocrs".into(),
};
let mut plan = TextPlan::for_words(["日\u{1f4a9}語"]);
for (i, font) in plan.fonts.iter_mut().enumerate() {
font.name = format!("PCOcr{i}");
}
assert_eq!(plan.missing(), 1, "the emoji has no glyph anywhere");
assert_eq!(plan.encoding(0).len(), 3, "it still gets an encoding: the font's ?");
let codes: Vec<u8> = plan.encoding(0).iter().map(|&(_, c)| c).collect();
let s = String::from_utf8(text_layer(&[w], &plan)).unwrap();
assert!(s.contains(&format!("Tm {} Tj\n", String::from_utf8_lossy(&pdfcraft_fonts::literal(&codes)))), "{s}");
}
#[test]
fn rotated_pages_keep_the_reading_direction() {
// A page turned 90°: image x runs up the page.
let to_user = |x: f32, y: f32| [y as f64, x as f64];
let w = PlacedWord::place(&Word { text: "Up".into(), rect: [0.0, 0.0, 50.0, 10.0] }, to_user);
let w = PlacedWord::place(&Word::new("Up", [0.0, 0.0, 50.0, 10.0], "ocrs"), to_user);
assert_eq!(w.across, [0.0, 50.0]);
assert_eq!(w.up, [-10.0, 0.0]);
}
/// The current ONNX pair wins when an installation also retains both legacy models.
#[test]
fn model_discovery_prefers_the_complete_onnx_pair() {
let dir = tempfile::tempdir().unwrap();
for file in ["text-detection.onnx", "text-recognition.onnx"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") });
assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient");
for file in ["text-detection.rten", "text-recognition.rten"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models");
}
/// An interrupted ONNX update still permits an intact legacy pair in the same directory.
#[test]
fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() {
for current in ["text-detection.onnx", "text-recognition.onnx"] {
let dir = tempfile::tempdir().unwrap();
for file in [current, "text-detection.rten", "text-recognition.rten"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(
Models::in_dir(dir.path()),
Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }),
"{current} alone must not hide a complete legacy pair"
);
}
}
/// Detection and recognition must come from one complete format pair, never a mixed update.
#[test]
fn model_discovery_refuses_mixed_format_pairs() {
for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] {
let dir = tempfile::tempdir().unwrap();
for file in pair {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair");
}
}
/// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair.
#[test]
fn model_discovery_keeps_legacy_only_installations() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(Models::in_dir(dir.path()), None);
std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap();
assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete");
std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap();
assert_eq!(
Models::in_dir(dir.path()),
Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") })
);
}
/// #103: every release package's layout puts the models where the installed app looks.
/// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}.
#[test]
@@ -443,6 +813,47 @@ mod tests {
assert!(word.rect[0] >= 10.0 && word.rect[0] < 60.0, "{word:?}");
}
/// The recognised words name their engine.
#[test]
fn ocrs_recognizer_names_its_words() {
let Ok(rec) = OcrsRecognizer::find() else {
eprintln!("skipped: OCR models not installed");
return;
};
assert_eq!(rec.id(), "ocrs");
assert!(rec.languages().iter().any(|(c, n)| c == "en" && n == "English"), "{:?}", rec.languages());
let (w, h, px) = test_image::hello();
let image = OcrImage::new(w, h, px).unwrap();
let lines = rec.recognize(&image, &RecognizeOptions::default()).unwrap();
let words: Vec<&Word> = lines.iter().flat_map(|l| &l.words).collect();
assert!(!words.is_empty());
assert!(words.iter().all(|w| w.source == "ocrs"), "{words:?}");
}
/// Hostile images are rejected with errors, never panics: zero sides, sides over the cap,
/// totals over the area cap (even with legal sides), buffers that do not fit, counts that
/// overflow.
#[test]
fn hostile_image_dimensions_are_rejected_not_panicked_at() {
assert!(matches!(OcrImage::new(0, 10, vec![]), Err(OcrError::EmptyImage)));
assert!(matches!(OcrImage::new(10, 0, vec![]), Err(OcrError::EmptyImage)));
assert!(matches!(OcrImage::new(u32::MAX, u32::MAX, vec![]), Err(OcrError::ImageTooLarge)));
assert!(matches!(OcrImage::new(MAX_SIDE + 1, 1, vec![0; 4]), Err(OcrError::ImageTooLarge)));
// 10 000 × 7 000 = 70 MP: legal on both sides, over the 64 MP area cap (no allocation
// is needed to reject it — the buffer is never inspected).
assert!(matches!(OcrImage::new(MAX_SIDE, 7000, vec![]), Err(OcrError::ImageTooLarge)));
// 8 000 × 8 000 = exactly the area cap: accepted shape (the short buffer proves the
// cap check passed), while one more row is refused.
assert!(matches!(OcrImage::new(8000, 8000, vec![0; 16]), Err(OcrError::ImageSize { .. })));
assert!(matches!(OcrImage::new(8000, 8001, vec![0; 16]), Err(OcrError::ImageTooLarge)));
assert!(matches!(OcrImage::new(2, 2, vec![0; 15]), Err(OcrError::ImageSize { .. })));
let ok = OcrImage::new(2, 2, vec![255; 16]).unwrap();
assert_eq!((ok.width(), ok.height()), (2, 2));
assert_eq!(ok.rgba().len(), 16);
assert_eq!(ok.gray(5, 5), Some(255), "clamped reads stay inside");
assert_eq!(ok.gray(-1, -1), Some(255));
}
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>) {
+583
View File
@@ -0,0 +1,583 @@
//! Combining engines. "ROVER" merges two engines' readings of one page: every primary word
//! adopts the best-overlapping unused secondary word when their boxes agree enough (IoU, below);
//! the higher confidence wins the text, box and confidence (a tie keeps the primary; against a
//! primary of unknown confidence the secondary must reach [`UNKNOWN_CONFIDENCE_BAR`]) and the
//! surviving word is named `"ROVER"`; secondary words nothing claimed are appended, keeping
//! their own engine's name.
use crate::{Line, OcrError, OcrImage, RecognizeOptions, Recognizer, Word};
/// How the chosen engines combine. The default is [`MergeStrategy::PrimaryOnly`] with the
/// default engine (ocrs), which is today's behavior.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum MergeStrategy {
/// Only the primary engine runs.
#[default]
PrimaryOnly,
/// The secondary engine joins only when the primary's reading looks weak: its mean word
/// confidence is under 70, or its confidence is unknown (words were read but none reports
/// a confidence — the ocrs engine). An unknown reading is never counted as 100; an empty
/// primary keeps the fast path (no words, nothing to double-check).
ConfidenceWeighted,
/// Both engines always run and their readings merge (with no secondary engine this
/// degrades to primary only).
RoverVote,
}
/// The mean of the confidences the words report, 0–100; `None` when the reading's confidence
/// is UNKNOWN — words were read but none reports a confidence (the ocrs engine's words).
/// Unknown is never counted as 100: there is no low-confidence signal, but a reading whose
/// confidence nobody knows is not therefore confident, so a confidence-weighted strategy
/// answers it by asking the secondary engine. An empty reading is `Some(100.0)`: no words
/// were read, so there is nothing to double-check and the fast path stands.
pub fn mean_confidence(lines: &[Line]) -> Option<f32> {
let mut total = 0usize;
let mut reported = (0.0f64, 0usize);
for word in lines.iter().flat_map(|l| &l.words) {
total += 1;
if let Some(c) = word.confidence {
reported.0 += c as f64;
reported.1 += 1;
}
}
match (total, reported.1) {
(0, _) => Some(100.0),
(_, 0) => None,
(_, n) => Some((reported.0 / n as f64) as f32),
}
}
/// Intersection over union of two `[left, top, right, bottom]` boxes. An empty intersection is
/// 0; degenerate or NaN boxes give 0 rather than panicking or poisoning the comparison.
pub fn iou(a: &[f32; 4], b: &[f32; 4]) -> f64 {
let l = a[0].max(b[0]);
let t = a[1].max(b[1]);
let r = a[2].min(b[2]);
let btm = a[3].min(b[3]);
if !(r > l && btm > t) {
return 0.0; // disjoint, touching, inverted, or NaN (all comparisons false)
}
let inter = (r - l) as f64 * (btm - t) as f64;
let area = |x: &[f32; 4]| ((x[2] - x[0]).max(0.0) as f64) * ((x[3] - x[1]).max(0.0) as f64);
let union = area(a) + area(b) - inter;
if union <= 0.0 {
return 0.0;
}
inter / union
}
/// The most primary×secondary word comparisons one ROVER merge will attempt. Known limit: a
/// merge over this cap is skipped — the primary reading stands alone, and the skip is noted,
/// never silent — so two enormous readings cannot turn the merge into quadratic work without
/// end. A real page is a few hundred words on each side, orders of magnitude under the cap.
pub const ROVER_MAX_COMPARISONS: usize = 100_000_000;
/// Whether the two readings are too large to merge: their word counts multiply past
/// [`ROVER_MAX_COMPARISONS`]. Shared by [`rover_merge`] (which then keeps the primary) and
/// [`recognize_with_strategy`] (which then notes the skip).
fn over_cap(primary: &[Line], secondary: &[Line]) -> bool {
let primary_words: usize = primary.iter().map(|l| l.words.len()).sum();
let secondary_words: usize = secondary.iter().map(|l| l.words.len()).sum();
primary_words.saturating_mul(secondary_words) > ROVER_MAX_COMPARISONS
}
/// The confidence a secondary word needs to replace a primary word whose confidence is
/// unknown (the ocrs engine reports none). It is the bar under which a reading counts as weak
/// ([`MergeStrategy::ConfidenceWeighted`] asks the second engine below it): a word the second
/// engine itself calls weak does not displace a reading nobody scored.
pub const UNKNOWN_CONFIDENCE_BAR: f32 = 70.0;
/// True when the secondary word's confidence `a` beats the primary's `b`. Both known: the
/// strictly higher one wins (a tie keeps the primary). A known confidence against an unknown
/// primary wins only at or above [`UNKNOWN_CONFIDENCE_BAR`]: an unknown confidence is neither
/// 0 nor 100, so a 5% reading must not replace it, while a confident one may. NaN never wins,
/// and with no confidence on the secondary side the primary stands.
fn higher(a: Option<f32>, b: Option<f32>) -> bool {
match (a, b) {
(Some(a), Some(b)) => a.partial_cmp(&b) == Some(std::cmp::Ordering::Greater),
(Some(a), None) => a >= UNKNOWN_CONFIDENCE_BAR,
_ => false,
}
}
/// Merge the two engines' readings. Primary words keep their order and lines; a word is
/// replaced by its overlapping secondary (IoU >= 0.5) only when that secondary is more
/// confident, and either way the merged word's [`Word::source`] is `"ROVER"`. Secondary words
/// no primary claimed are appended, in their own reading order and lines, under their own
/// engine's name (`secondary_id`). Known limit: over [`ROVER_MAX_COMPARISONS`] comparisons
/// (word counts multiplied) the primary reading is kept as is, unmerged; the strategy runner
/// names that skip in a note.
pub fn rover_merge(primary: Vec<Line>, secondary: Vec<Line>, secondary_id: &str) -> Vec<Line> {
if over_cap(&primary, &secondary) {
return primary; // over the comparison cap: the primary stands alone
}
let mut primary = primary;
// The secondary's words, marked `None` once a primary word claimed them, grouped by their
// own lines so the unclaimed ones can be appended in order.
let mut unused: Vec<Vec<Option<Word>>> = secondary.into_iter().map(|l| l.words.into_iter().map(Some).collect()).collect();
for line in &mut primary {
for word in &mut line.words {
// The best-overlapping unused secondary word, if any beats IoU 0.5.
let mut best: Option<(f64, usize, usize)> = None;
for (li, words) in unused.iter().enumerate() {
for (wi, slot) in words.iter().enumerate() {
let Some(w) = slot else { continue };
let score = iou(&word.rect, &w.rect);
if score >= 0.5 && best.is_none_or(|(b, _, _)| score > b) {
best = Some((score, li, wi));
}
}
}
let Some((_, li, wi)) = best else { continue };
let claimed = unused.get_mut(li).and_then(|l| l.get_mut(wi)).and_then(|slot| slot.take());
let Some(claim) = claimed else { continue };
if higher(claim.confidence, word.confidence) {
*word = Word { source: "ROVER".into(), ..claim };
} else {
// Tie (or the primary wins): keep the primary's text, box and confidence, but
// record that this reading is the engines' agreed one.
word.source = "ROVER".into();
}
}
}
// Append the secondary words nothing claimed, line by line, under their engine's name
// (`secondary_id` backfills words that name no source of their own).
let mut merged = primary;
for line in unused {
let words: Vec<Word> = line
.into_iter()
.flatten()
.map(|mut w| {
if w.source.is_empty() {
w.source = secondary_id.into();
}
w
})
.collect();
if !words.is_empty() {
merged.push(Line { words });
}
}
merged
}
/// What a strategy run produced: the merged (or primary-only) reading, whether the secondary
/// engine's reading is part of it, and the notes recorded along the way.
#[derive(Clone, Debug, PartialEq, Default)]
pub struct StrategyOutcome {
pub lines: Vec<Line>,
/// Whether the secondary engine's reading is part of `lines`: true only when the merge
/// actually happened. A secondary that failed, or whose reading was discarded over the
/// comparison cap, is `false`, with a note in [`StrategyOutcome::notes`] saying why.
pub secondary_ran: bool,
/// Why the page's reading is partial, if it is. The secondary engine is an enhancement,
/// never a gate: when it is configured but fails, the primary reading is kept and the
/// failure is noted here — a page is never skipped because a partner could not read it.
/// Only a primary failure is an [`OcrError`].
pub notes: Vec<String>,
}
/// The note recorded when an ensemble partner fails: the page keeps the primary reading and
/// the cause is named for the report.
fn degraded_note(id: &str, error: &OcrError) -> String {
format!("the {id} engine failed ({error}) — the page keeps the primary reading alone")
}
/// The note recorded when the two readings are too large to merge: the page keeps the primary
/// reading alone and the discarded partner reading is named, never silent.
fn cap_note(id: &str, primary_words: usize, secondary_words: usize) -> String {
format!(
"the readings were too large to merge ({primary_words} × {secondary_words} comparisons, over the cap of {ROVER_MAX_COMPARISONS}) — the page keeps the primary reading alone and the {id} reading is unused"
)
}
/// Run the engines per `strategy` and merge. See [`StrategyOutcome`] for what comes back.
pub fn recognize_with_strategy(
primary: &dyn Recognizer,
secondary: Option<&dyn Recognizer>,
strategy: MergeStrategy,
image: &OcrImage,
options: &RecognizeOptions,
) -> Result<StrategyOutcome, OcrError> {
// The shared shape of the ensemble strategies: the primary has read, the secondary joins
// or fails (a failure degrades to the primary, with a note).
let join = |p: Vec<Line>, sec: &dyn Recognizer| match sec.recognize(image, options) {
// Over the comparison cap the merge is skipped: the secondary's reading was read but
// is not part of the result, so the outcome says so — primary only, honestly not
// merged, with the skip named.
Ok(s) if over_cap(&p, &s) => {
let (primary_words, secondary_words) = (p.iter().map(|l| l.words.len()).sum::<usize>(), s.iter().map(|l| l.words.len()).sum::<usize>());
StrategyOutcome { lines: p, secondary_ran: false, notes: vec![cap_note(sec.id(), primary_words, secondary_words)] }
}
Ok(s) => StrategyOutcome { lines: rover_merge(p, s, sec.id()), secondary_ran: true, notes: Vec::new() },
Err(e) => StrategyOutcome { lines: p, secondary_ran: false, notes: vec![degraded_note(sec.id(), &e)] },
};
match strategy {
MergeStrategy::PrimaryOnly => Ok(StrategyOutcome { lines: primary.recognize(image, options)?, secondary_ran: false, notes: Vec::new() }),
MergeStrategy::RoverVote => match secondary {
None => Ok(StrategyOutcome { lines: primary.recognize(image, options)?, secondary_ran: false, notes: Vec::new() }),
Some(sec) => {
let p = primary.recognize(image, options)?;
Ok(join(p, sec))
}
},
MergeStrategy::ConfidenceWeighted => {
let p = primary.recognize(image, options)?;
match mean_confidence(&p) {
// Confident enough — or an empty reading: the primary stands alone.
Some(mean) if mean >= UNKNOWN_CONFIDENCE_BAR => Ok(StrategyOutcome { lines: p, secondary_ran: false, notes: Vec::new() }),
// A weak mean, or an unknown confidence (words, none reported): the secondary
// runs.
_ => match secondary {
None => Ok(StrategyOutcome { lines: p, secondary_ran: false, notes: Vec::new() }),
Some(sec) => Ok(join(p, sec)),
},
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
/// A fake engine: returns canned lines, counts recognize calls, never available.
struct Fake {
id: &'static str,
lines: Vec<Line>,
calls: AtomicUsize,
fail: bool,
}
impl Fake {
fn new(id: &'static str, words: &[(&str, [f32; 4], f32)]) -> Fake {
Fake {
id,
lines: vec![Line { words: words.iter().map(|(t, r, c)| Word::new(*t, *r, id).with_confidence(*c)).collect() }],
calls: AtomicUsize::new(0),
fail: false,
}
}
fn empty(id: &'static str) -> Fake {
Fake { id, lines: Vec::new(), calls: AtomicUsize::new(0), fail: false }
}
/// An engine like ocrs: words, but no confidence reported on any of them.
fn unknown(id: &'static str, n: usize) -> Fake {
Fake {
id,
lines: vec![Line {
words: (0..n).map(|i| Word::new(format!("w{i}"), [i as f32 * 10.0, 0.0, i as f32 * 10.0 + 8.0, 10.0], id)).collect(),
}],
calls: AtomicUsize::new(0),
fail: false,
}
}
}
impl Recognizer for Fake {
fn id(&self) -> &'static str {
self.id
}
fn available(&self) -> bool {
!self.fail
}
fn languages(&self) -> Vec<(String, String)> {
vec![]
}
fn recognize(&self, _image: &OcrImage, _options: &RecognizeOptions) -> Result<Vec<Line>, OcrError> {
self.calls.fetch_add(1, Ordering::SeqCst);
if self.fail {
return Err(OcrError::Recognize("the fake engine failed".into()));
}
Ok(self.lines.clone())
}
}
fn image() -> OcrImage {
OcrImage::new(4, 4, vec![255; 64]).unwrap()
}
fn words(lines: &[Line]) -> Vec<&Word> {
lines.iter().flat_map(|l| &l.words).collect()
}
#[test]
fn iou_of_disjoint_touching_and_nan_boxes_is_zero() {
let a = [0.0, 0.0, 10.0, 10.0];
assert_eq!(iou(&a, &[20.0, 0.0, 30.0, 10.0]), 0.0, "disjoint");
assert_eq!(iou(&a, &[10.0, 0.0, 20.0, 10.0]), 0.0, "touching edges are an empty intersection");
assert_eq!(iou(&a, &[f32::NAN, 0.0, 10.0, 10.0]), 0.0, "NaN boxes score 0, not a panic");
assert_eq!(iou(&a, &a), 1.0, "identical boxes");
assert!((iou(&a, &[5.0, 0.0, 15.0, 10.0]) - 1.0 / 3.0).abs() < 1e-9, "half overlap is IoU 1/3");
}
/// With nothing on the primary side, every secondary word is appended under the secondary
/// engine's own name.
#[test]
fn rover_appends_everything_when_the_primary_is_empty() {
let primary = Vec::new();
let secondary = vec![Line { words: vec![Word::new("sole", [0.0, 0.0, 9.0, 9.0], "tesseract").with_confidence(80.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
assert_eq!(words(&merged).len(), 1);
assert_eq!(merged[0].words[0].text, "sole");
assert_eq!(merged[0].words[0].source, "tesseract", "unclaimed words keep their own engine");
}
/// Non-overlapping secondary words never displace primary words; they are appended.
#[test]
fn rover_appends_non_overlapping_secondary_words() {
let primary = vec![Line { words: vec![Word::new("kept", [0.0, 0.0, 10.0, 10.0], "ocrs").with_confidence(50.0)] }];
let secondary = vec![Line { words: vec![Word::new("extra", [100.0, 100.0, 120.0, 110.0], "tesseract").with_confidence(99.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
let w = words(&merged);
assert_eq!(w.len(), 2);
assert_eq!((w[0].text.as_str(), w[0].source.as_str()), ("kept", "ocrs"), "the more confident but disjoint word does not win");
assert_eq!((w[1].text.as_str(), w[1].source.as_str()), ("extra", "tesseract"));
}
/// The higher-confidence word of an overlapping pair wins text, box and confidence, and is
/// named ROVER.
#[test]
fn rover_higher_confidence_wins_and_is_named_rover() {
let primary = vec![Line { words: vec![Word::new("helo", [0.0, 0.0, 10.0, 10.0], "ocrs").with_confidence(60.0)] }];
let secondary = vec![Line { words: vec![Word::new("hello", [1.0, 0.0, 11.0, 10.0], "tesseract").with_confidence(95.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
let w = words(&merged);
assert_eq!(w.len(), 1, "the secondary word was claimed, not appended");
assert_eq!(w[0].text, "hello");
assert_eq!(w[0].rect, [1.0, 0.0, 11.0, 10.0]);
assert_eq!(w[0].confidence, Some(95.0));
assert_eq!(w[0].source, "ROVER");
}
/// A tie keeps the primary word (still named ROVER, as an agreed reading).
#[test]
fn rover_tie_keeps_the_primary() {
let primary = vec![Line { words: vec![Word::new("primary", [0.0, 0.0, 10.0, 10.0], "ocrs").with_confidence(80.0)] }];
let secondary = vec![Line { words: vec![Word::new("secondary", [0.0, 0.0, 10.0, 10.0], "tesseract").with_confidence(80.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
let w = words(&merged);
assert_eq!(w.len(), 1);
assert_eq!(w[0].text, "primary");
assert_eq!(w[0].source, "ROVER");
}
/// Boxes under the 0.5 IoU bar never merge, even when the secondary is more confident.
#[test]
fn rover_below_half_iou_does_not_merge() {
let primary = vec![Line { words: vec![Word::new("kept", [0.0, 0.0, 10.0, 10.0], "ocrs").with_confidence(50.0)] }];
// IoU 10·10 / (100 + 169 - 25) = 100/244 ≈ 0.41 < 0.5.
let secondary = vec![Line { words: vec![Word::new("other", [5.0, 0.0, 23.0, 20.0], "tesseract").with_confidence(99.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
let w = words(&merged);
assert_eq!(w.len(), 2);
assert_eq!(w[0].text, "kept");
assert_eq!(w[0].source, "ocrs");
}
/// Merged words keep their line; the merge never panics on NaN boxes.
#[test]
fn rover_survives_nan_boxes() {
let primary = vec![Line { words: vec![Word::new("nan", [f32::NAN, 0.0, 10.0, 10.0], "ocrs").with_confidence(50.0)] }];
let secondary = vec![Line { words: vec![Word::new("real", [0.0, 0.0, 10.0, 10.0], "tesseract").with_confidence(90.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
let w = words(&merged);
assert_eq!(w.len(), 2, "a NaN primary box merges with nothing");
assert_eq!(w[0].text, "nan");
assert_eq!(w[1].text, "real");
}
/// Only reported confidences count. An empty reading is Some(100) (nothing to
/// double-check); words without a single confidence are UNKNOWN (`None`), never 100.
#[test]
fn mean_confidence_distinguishes_empty_from_unknown() {
assert_eq!(mean_confidence(&[]), Some(100.0));
assert_eq!(mean_confidence(&[Line { words: vec![Word::new("a", [0.0; 4], "ocrs")] }]), None, "words, no confidences: unknown");
let lines = vec![Line {
words: vec![
Word::new("a", [0.0; 4], "e").with_confidence(40.0),
Word::new("b", [0.0; 4], "e").with_confidence(80.0),
Word::new("c", [0.0; 4], "e"),
],
}];
assert_eq!(mean_confidence(&lines), Some(60.0), "reported confidences are averaged");
}
/// ROVER's preference needs a meaningful comparison: a known confidence beats an unknown
/// one (the ocrs primary yields to a confident tesseract reading), and None vs None keeps
/// the primary.
#[test]
fn rover_known_confidence_beats_unknown_and_none_vs_none_keeps_the_primary() {
let primary = vec![Line { words: vec![Word::new("helo", [0.0, 0.0, 10.0, 10.0], "ocrs")] }];
let secondary = vec![Line { words: vec![Word::new("hello", [0.0, 0.0, 10.0, 10.0], "tesseract").with_confidence(95.0)] }];
let merged = rover_merge(primary, secondary, "tesseract");
assert_eq!(
(merged[0].words[0].text.as_str(), merged[0].words[0].confidence, merged[0].words[0].source.as_str()),
("hello", Some(95.0), "ROVER")
);
let primary = vec![Line { words: vec![Word::new("primary", [0.0, 0.0, 10.0, 10.0], "ocrs")] }];
let secondary = vec![Line { words: vec![Word::new("secondary", [0.0, 0.0, 10.0, 10.0], "tesseract")] }];
let merged = rover_merge(primary, secondary, "tesseract");
assert_eq!(
(merged[0].words[0].text.as_str(), merged[0].words[0].source.as_str()),
("primary", "ROVER"),
"unknown vs unknown is not a comparison: the primary stands"
);
}
/// Over the comparison cap the merge is skipped and the primary reading stands alone (a
/// documented known limit, bounding the merge's quadratic core).
#[test]
fn rover_over_the_comparison_cap_keeps_the_primary() {
// 11 000 × 11 000 = 121 M comparisons > the 100 M cap.
let words = |id: &str| (0..11_000).map(|i| Word::new(format!("w{i}"), [i as f32, 0.0, i as f32 + 5.0, 10.0], id)).collect::<Vec<_>>();
let primary = vec![Line { words: words("ocrs") }];
let secondary = vec![Line { words: words("tesseract") }];
let merged = rover_merge(primary, secondary, "tesseract");
assert_eq!(merged.len(), 1);
assert!(merged[0].words.iter().all(|w| w.source == "ocrs"), "every primary word stands, unmerged");
assert_eq!(merged[0].words.len(), 11_000);
// Under the cap, merging still happens (a small overlap is replaced and named ROVER).
let small_primary = vec![Line { words: vec![Word::new("kept", [0.0, 0.0, 10.0, 10.0], "ocrs").with_confidence(50.0)] }];
let small_secondary = vec![Line { words: vec![Word::new("wins", [0.0, 0.0, 10.0, 10.0], "tesseract").with_confidence(95.0)] }];
let merged = rover_merge(small_primary, small_secondary, "tesseract");
assert_eq!((merged[0].words[0].text.as_str(), merged[0].words[0].source.as_str()), ("wins", "ROVER"));
}
/// Over the comparison cap the merge is skipped honestly at the strategy level: the page
/// keeps the primary reading alone, the skip is named in a note, and `secondary_ran` does
/// not claim the discarded reading is part of the result (though the secondary really did
/// run — its work was spent, its reading unused).
#[test]
fn rover_cap_hit_reports_primary_only_with_a_note_and_an_honest_flag() {
let mut primary = Fake::empty("ocrs");
primary.lines =
vec![Line { words: (0..11_000).map(|i| Word::new(format!("w{i}"), [i as f32, 0.0, i as f32 + 5.0, 10.0], "ocrs")).collect() }];
let mut secondary = Fake::empty("tesseract");
secondary.lines =
vec![Line { words: (0..11_000).map(|i| Word::new(format!("w{i}"), [i as f32, 0.0, i as f32 + 5.0, 10.0], "tesseract")).collect() }];
let outcome = recognize_with_strategy(&primary, Some(&secondary), MergeStrategy::RoverVote, &image(), &RecognizeOptions::default()).unwrap();
assert_eq!(secondary.calls.load(Ordering::SeqCst), 1, "the secondary really ran");
assert_eq!(outcome.lines.len(), 1);
assert_eq!(outcome.lines[0].words.len(), 11_000, "primary only");
assert!(outcome.lines[0].words.iter().all(|w| w.source == "ocrs"), "every primary word stands, unmerged");
assert!(!outcome.secondary_ran, "the secondary's reading is not part of the result");
assert_eq!(outcome.notes.len(), 1, "{:?}", outcome.notes);
assert!(outcome.notes[0].contains("merge"), "{:?}", outcome.notes[0]);
assert!(outcome.notes[0].contains("tesseract"), "{:?}", outcome.notes[0]);
// Under the cap the same strategy merges and reports the secondary honestly.
let primary = Fake::new("ocrs", &[("kept", [0.0, 0.0, 10.0, 10.0], 50.0)]);
let secondary = Fake::new("tesseract", &[("wins", [0.0, 0.0, 10.0, 10.0], 95.0)]);
let outcome = recognize_with_strategy(&primary, Some(&secondary), MergeStrategy::RoverVote, &image(), &RecognizeOptions::default()).unwrap();
assert!(outcome.secondary_ran && outcome.notes.is_empty());
assert_eq!((outcome.lines[0].words[0].text.as_str(), outcome.lines[0].words[0].source.as_str()), ("wins", "ROVER"));
}
#[test]
fn primary_only_never_runs_the_secondary() {
let primary = Fake::new("ocrs", &[("w", [0.0; 4], 90.0)]);
let secondary = Fake::empty("tesseract");
let outcome =
recognize_with_strategy(&primary, Some(&secondary), MergeStrategy::PrimaryOnly, &image(), &RecognizeOptions::default()).unwrap();
assert_eq!(primary.calls.load(Ordering::SeqCst), 1);
assert_eq!(secondary.calls.load(Ordering::SeqCst), 0);
assert!(!outcome.secondary_ran);
assert!(outcome.notes.is_empty());
assert_eq!(words(&outcome.lines).len(), 1);
}
#[test]
fn confidence_weighted_runs_the_secondary_only_on_a_weak_primary() {
let weak = Fake::new("ocrs", &[("w", [0.0; 4], 40.0)]);
let strong = Fake::new("ocrs", &[("w", [0.0; 4], 95.0)]);
let secondary = Fake::empty("tesseract");
let outcome =
recognize_with_strategy(&weak, Some(&secondary), MergeStrategy::ConfidenceWeighted, &image(), &RecognizeOptions::default()).unwrap();
assert!(outcome.secondary_ran, "mean 40 < 70 → the secondary joins");
let outcome =
recognize_with_strategy(&strong, Some(&secondary), MergeStrategy::ConfidenceWeighted, &image(), &RecognizeOptions::default()).unwrap();
assert!(!outcome.secondary_ran, "mean 95 >= 70 → primary alone");
// An empty primary counts as 100: no signal, no secondary run.
let empty = Fake::empty("ocrs");
let outcome =
recognize_with_strategy(&empty, Some(&secondary), MergeStrategy::ConfidenceWeighted, &image(), &RecognizeOptions::default()).unwrap();
assert!(!outcome.secondary_ran);
assert_eq!(secondary.calls.load(Ordering::SeqCst), 1, "only the weak page ran the secondary");
}
/// The ocrs engine reports no confidences: unknown is not 100, so the secondary runs —
/// and its known reading wins the overlapping word (the ensemble's whole point).
#[test]
fn confidence_weighted_runs_the_secondary_on_an_unknown_primary() {
let ocrs_like = Fake::unknown("ocrs", 1);
let secondary = Fake::new("tesseract", &[("hello", [0.0, 0.0, 10.0, 10.0], 95.0)]);
let outcome =
recognize_with_strategy(&ocrs_like, Some(&secondary), MergeStrategy::ConfidenceWeighted, &image(), &RecognizeOptions::default()).unwrap();
assert!(outcome.secondary_ran, "an unknown confidence asks the secondary");
assert_eq!((outcome.lines[0].words[0].text.as_str(), outcome.lines[0].words[0].confidence), ("hello", Some(95.0)));
assert_eq!(outcome.lines[0].words[0].source, "ROVER");
assert_eq!(secondary.calls.load(Ordering::SeqCst), 1);
}
/// Against a primary word of unknown confidence, a secondary word wins only when it is
/// confident (at or above the weak-reading bar): a 5% reading does not replace a word nobody
/// scored, a 70% one does. The merged word is named ROVER either way.
#[test]
fn an_unknown_primary_word_yields_only_to_a_confident_secondary() {
let merge = |conf: f32| {
let secondary = vec![Line { words: vec![Word::new("other", [0.0, 0.0, 10.0, 10.0], "tesseract").with_confidence(conf)] }];
let primary = Fake::unknown("ocrs", 1).lines;
rover_merge(primary, secondary, "tesseract").remove(0).words.remove(0)
};
let weak = merge(5.0);
assert_eq!((weak.text.as_str(), weak.confidence, weak.source.as_str()), ("w0", None, "ROVER"), "a 5% word keeps the primary");
let below = merge(UNKNOWN_CONFIDENCE_BAR - 0.5);
assert_eq!(below.text, "w0");
let at_bar = merge(UNKNOWN_CONFIDENCE_BAR);
assert_eq!((at_bar.text.as_str(), at_bar.confidence), ("other", Some(UNKNOWN_CONFIDENCE_BAR)), "a confident word wins");
let nan = merge(f32::NAN);
assert_eq!(nan.text, "w0", "NaN (unknown after clamping) never wins");
}
#[test]
fn rover_vote_always_runs_both_and_degrades_without_a_secondary() {
let primary = Fake::new("ocrs", &[("w", [0.0; 4], 95.0)]);
let secondary = Fake::empty("tesseract");
let outcome = recognize_with_strategy(&primary, Some(&secondary), MergeStrategy::RoverVote, &image(), &RecognizeOptions::default()).unwrap();
assert!(outcome.secondary_ran);
assert_eq!(secondary.calls.load(Ordering::SeqCst), 1);
// No secondary: primary only.
let outcome = recognize_with_strategy(&primary, None, MergeStrategy::RoverVote, &image(), &RecognizeOptions::default()).unwrap();
assert!(!outcome.secondary_ran);
}
/// A configured secondary that fails degrades to the primary reading, with the failure
/// recorded as a note — never a skipped page, never a silently partial reading without a
/// trace. Both ensemble strategies degrade; a primary failure is still an error.
#[test]
fn a_failing_secondary_degrades_to_the_primary_with_a_note() {
let mut secondary = Fake::empty("tesseract");
secondary.fail = true;
let primary = Fake::new("ocrs", &[("w", [0.0; 4], 95.0)]);
let outcome = recognize_with_strategy(&primary, Some(&secondary), MergeStrategy::RoverVote, &image(), &RecognizeOptions::default()).unwrap();
assert_eq!(words(&outcome.lines).len(), 1, "the primary reading stands");
assert_eq!(outcome.lines[0].words[0].text, "w");
assert!(!outcome.secondary_ran);
assert_eq!(outcome.notes.len(), 1);
assert!(outcome.notes[0].contains("tesseract"), "{:?}", outcome.notes[0]);
// The confidence-weighted strategy degrades the same way on its weak path.
let weak = Fake::new("ocrs", &[("w", [0.0; 4], 40.0)]);
let outcome =
recognize_with_strategy(&weak, Some(&secondary), MergeStrategy::ConfidenceWeighted, &image(), &RecognizeOptions::default()).unwrap();
assert_eq!(words(&outcome.lines).len(), 1);
assert_eq!(outcome.notes.len(), 1, "{:?}", outcome.notes);
// A failing primary is still an error: there is nothing to degrade to.
let mut failing_primary = Fake::new("ocrs", &[("w", [0.0; 4], 95.0)]);
failing_primary.fail = true;
let r = recognize_with_strategy(&failing_primary, Some(&secondary), MergeStrategy::RoverVote, &image(), &RecognizeOptions::default());
assert!(r.is_err());
}
}
+251
View File
@@ -0,0 +1,251 @@
//! The pure-Rust engine: the ocrs crate with its pre-trained models, wrapped as a
//! [`Recognizer`] (`OcrsRecognizer`). Reading is offline and local; the models do not report a
//! confidence, so its words have `confidence: None`.
use std::path::{Path, PathBuf};
use super::{Line, MAX_PIXELS, MAX_SIDE, OcrError, OcrImage, RecognizeOptions, Recognizer, Word};
/// The validated byte length of a `width` × `height` RGBA raster, with the same caps
/// [`OcrImage::new`] applies: zero sides are empty, sides over [`MAX_SIDE`] or totals over
/// [`MAX_PIXELS`] are too large. `Ocr::recognize` takes raw dimensions (they come from
/// callers, ultimately from documents, so they are untrusted), and the naive
/// `width * height * 4` overflows `u32` for absurd values — the caps are checked first, in
/// `usize`, so nothing overflows and nothing huge is ever touched.
fn checked_len(width: u32, height: u32) -> Result<usize, OcrError> {
if width == 0 || height == 0 {
return Err(OcrError::EmptyImage);
}
if width > MAX_SIDE || height > MAX_SIDE {
return Err(OcrError::ImageTooLarge);
}
let pixels = (width as usize).checked_mul(height as usize).ok_or(OcrError::ImageTooLarge)?;
if pixels > MAX_PIXELS as usize {
return Err(OcrError::ImageTooLarge);
}
pixels.checked_mul(4).ok_or(OcrError::ImageTooLarge)
}
/// The languages the models read (ISO 639-1); all use the Latin alphabet without accents.
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";
pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
/// Where the two model files are.
#[derive(Clone, Debug, PartialEq)]
pub struct Models {
pub detection: PathBuf,
pub recognition: PathBuf,
}
impl Models {
pub fn dirs_beside_exe(exe_dir: &Path) -> Vec<PathBuf> {
vec![exe_dir.join("models"), exe_dir.join("../Resources/models"), exe_dir.join("../share/pdfcraft/models")]
}
/// The models in `dir`, preferring current ONNX files over legacy RTEN files. An incomplete
/// update never mixes formats or hides an intact legacy pair.
pub fn in_dir(dir: &Path) -> Option<Models> {
[(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| {
let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) };
(m.detection.is_file() && m.recognition.is_file()).then_some(m)
})
}
/// Look for the models: `$PDFCRAFT_MODELS`, then `models/` beside the executable (and
/// `Resources/models` in a macOS bundle), then the source tree's `assets/models/`.
pub fn find() -> Option<Models> {
Self::search_dirs().iter().find_map(|d| Self::in_dir(d))
}
/// The directories [`Models::find`] looks in, in order.
pub fn search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(d) = std::env::var_os("PDFCRAFT_MODELS") {
dirs.push(PathBuf::from(d));
}
if let Some(exe) = std::env::current_exe().ok().and_then(|e| e.parent().map(Path::to_path_buf)) {
dirs.extend(Self::dirs_beside_exe(&exe));
}
dirs.push(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../assets/models"));
dirs
}
}
/// A loaded recogniser. Loading takes a moment; keep one and reuse it.
pub struct Ocr {
engine: ocrs::OcrEngine,
}
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()));
let params = ocrs::OcrEngineParams {
detection_model: Some(load(&models.detection)?),
recognition_model: Some(load(&models.recognition)?),
alphabet: Some(ALPHABET.into()),
..Default::default()
};
let engine = ocrs::OcrEngine::new(params).map_err(|e| OcrError::Load("ocr engine".into(), e.to_string()))?;
Ok(Ocr { engine })
}
/// Load the models found by [`Models::find`].
pub fn find() -> Result<Ocr, OcrError> {
Self::load(&Models::find().ok_or(OcrError::NoModels)?)
}
/// Recognise the text in an RGBA (or RGB, or grey) image, `width` × `height` pixels.
pub fn recognize(&self, pixels: &[u8], width: u32, height: u32) -> Result<Vec<Line>, OcrError> {
if pixels.is_empty() {
return Err(OcrError::EmptyImage);
}
let want = checked_len(width, height)?;
// ocrs wants 1 or 3 channels.
let rgb: std::borrow::Cow<[u8]> = if pixels.len() == want {
pixels.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect::<Vec<u8>>().into()
} else {
pixels.into()
};
let err = |e: &dyn std::fmt::Display| OcrError::Recognize(e.to_string());
let source = ocrs::ImageSource::from_bytes(&rgb, (width, height)).map_err(|e| err(&e))?;
let input = self.engine.prepare_input(source).map_err(|e| err(&e))?;
let found = self.engine.detect_words(&input).map_err(|e| err(&e))?;
let lines = self.engine.find_text_lines(&input, &found);
let read = self.engine.recognize_text(&input, &lines).map_err(|e| err(&e))?;
use ocrs::TextItem;
Ok(read
.into_iter()
.flatten()
.map(|line| Line {
words: line
.words()
.map(|w| {
let r = w.bounding_rect();
Word::new(w.to_string(), [r.left() as f32, r.top() as f32, r.right() as f32, r.bottom() as f32], "ocrs")
})
.filter(|w| !w.text.trim().is_empty())
.collect(),
})
.filter(|l| !l.words.is_empty())
.collect())
}
}
/// The ocrs engine as a [`Recognizer`] (the default engine).
pub struct OcrsRecognizer {
engine: Ocr,
}
impl OcrsRecognizer {
/// Load the models from `models`.
pub fn load(models: &Models) -> Result<OcrsRecognizer, OcrError> {
Ok(OcrsRecognizer { engine: Ocr::load(models)? })
}
/// Load the models found by [`Models::find`].
pub fn find() -> Result<OcrsRecognizer, OcrError> {
Self::load(&Models::find().ok_or(OcrError::NoModels)?)
}
}
impl Recognizer for OcrsRecognizer {
fn id(&self) -> &'static str {
"ocrs"
}
fn available(&self) -> bool {
Models::find().is_some()
}
fn languages(&self) -> Vec<(String, String)> {
LANGUAGES.iter().map(|(c, n)| ((*c).to_string(), (*n).to_string())).collect()
}
fn recognize(&self, image: &OcrImage, _options: &RecognizeOptions) -> Result<Vec<Line>, OcrError> {
self.engine.recognize(image.rgba(), image.width(), image.height())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Raw dimensions are untrusted (they come from callers, ultimately from documents): zero
/// sides, sides or pixel totals over the caps, and products that would overflow the naive
/// `width * height * 4` are errors, never panics, before anything is touched.
#[test]
fn raw_dimensions_are_capped_and_checked_not_panicked_at() {
assert!(matches!(checked_len(0, 10), Err(OcrError::EmptyImage)));
assert!(matches!(checked_len(10, 0), Err(OcrError::EmptyImage)));
assert!(matches!(checked_len(MAX_SIDE + 1, 1), Err(OcrError::ImageTooLarge)));
// 65536² would wrap u32 in the old `width * height * 4`; the side cap refuses it first.
assert!(matches!(checked_len(u32::MAX, u32::MAX), Err(OcrError::ImageTooLarge)));
// Legal sides whose product is over the 64 MP area cap.
assert!(matches!(checked_len(MAX_SIDE, 7000), Err(OcrError::ImageTooLarge)));
assert_eq!(checked_len(2, 2).unwrap(), 16);
// Exactly the area cap fits: 8000 × 8000 = 64 MP.
assert_eq!(checked_len(8000, 8000).unwrap(), 8000usize * 8000 * 4);
}
/// The current ONNX pair wins when an installation also retains both legacy models.
#[test]
fn model_discovery_prefers_the_complete_onnx_pair() {
let dir = tempfile::tempdir().unwrap();
for file in ["text-detection.onnx", "text-recognition.onnx"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") });
assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient");
for file in ["text-detection.rten", "text-recognition.rten"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models");
}
/// An interrupted ONNX update still permits an intact legacy pair in the same directory.
#[test]
fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() {
for current in ["text-detection.onnx", "text-recognition.onnx"] {
let dir = tempfile::tempdir().unwrap();
for file in [current, "text-detection.rten", "text-recognition.rten"] {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(
Models::in_dir(dir.path()),
Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }),
"{current} alone must not hide a complete legacy pair"
);
}
}
/// Detection and recognition must come from one complete format pair, never a mixed update.
#[test]
fn model_discovery_refuses_mixed_format_pairs() {
for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] {
let dir = tempfile::tempdir().unwrap();
for file in pair {
std::fs::write(dir.path().join(file), b"model").unwrap();
}
assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair");
}
}
/// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair.
#[test]
fn model_discovery_keeps_legacy_only_installations() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(Models::in_dir(dir.path()), None);
std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap();
assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete");
std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap();
assert_eq!(
Models::in_dir(dir.path()),
Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") })
);
}
}
+973
View File
@@ -0,0 +1,973 @@
//! Preprocessing: a pure function between the page raster and recognition, in fixed order —
//! (a) DPI normalization, then the four opt-in steps (b) orientation, (c) deskew, (d) denoise,
//! (e) binarization. Normalization exists for those steps (their windows and thresholds are
//! calibrated for 300 dpi), so it runs only when at least one of them is on: with every step
//! off the raster is handed to the engines exactly as it was rendered. (Enlarging a render
//! on its own changes what the models read: a 72 dpi scan of "due: €250" read as "ue: ?250"
//! after a 4× bilinear enlargement.) Everything is plain Rust on the RGBA raster: no
//! Leptonica, no process, no new dependencies.
//!
//! Every geometric step records its inverse; the result carries one exact
//! [`InverseTransform`] (an affine map, composed in f64), and callers map every word box
//! through it back to the *original* raster coordinates before placing the words.
//!
//! The heuristics are deliberately conservative and panic-free: an image too small to judge,
//! a blank page, or a score without a clear margin is a no-op, never a guess.
use crate::{MAX_PIXELS, MAX_SIDE, OcrError, OcrImage};
/// The four opt-in steps. All off by default: a scan is passed through as rendered.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct PreprocessOptions {
/// Detect pages scanned at 90°/180°/270° and turn them upright.
pub auto_rotate: bool,
/// Straighten skewed text lines.
pub deskew: bool,
/// 3×3 median filter (salt-and-pepper noise).
pub denoise: bool,
/// Sauvola binarization (window 8, k 0.34).
pub binarize: bool,
}
impl PreprocessOptions {
/// Whether any step is on (and so the raster is normalized to 300 dpi first).
pub fn any(&self) -> bool {
self.auto_rotate || self.deskew || self.denoise || self.binarize
}
}
/// A preprocessed raster: the image to recognize, the exact way back to the input raster, and
/// the resolution the image now stands for.
#[derive(Debug)]
pub struct Preprocessed {
pub image: OcrImage,
/// Maps pixel coordinates in [`Preprocessed::image`] back to the input raster's.
pub inverse_transform: InverseTransform,
/// The image's resolution in dots per inch: 300 after normalization (only when a step is
/// on), or the source's own
/// when it already rendered at 300 or more.
pub dpi: f32,
}
/// Preprocess `image` (rendered at `source_dpi`) per `options`, in the fixed order. The image
/// is consumed: the common path (every step off, or a no-op) hands the raster back without
/// copying it, and a step that rewrites pixels replaces it.
pub fn preprocess(image: OcrImage, source_dpi: f32, options: &PreprocessOptions) -> Result<Preprocessed, OcrError> {
let mut current = image;
let mut inverse = InverseTransform::identity();
// (a) DPI normalization: for the steps below, a scan under 300 dpi is enlarged by 300/src so
// they see letter-sized strokes. With every step off nothing is resampled. A broken dpi
// (zero, negative, NaN) is left alone.
let mut dpi = if source_dpi.is_finite() && source_dpi >= 1.0 { source_dpi } else { 300.0 };
if options.any() && dpi < 300.0 {
let scale = f64::from(300.0 / dpi);
current = resize_bilinear(&current, scale)?;
inverse = compose(&inverse, &InverseTransform::scale(1.0 / scale));
dpi = 300.0;
}
// (b) Orientation: turn 90/180/270 scans upright (opt-in, skipped for small images).
if options.auto_rotate {
let (w, h) = (current.width, current.height);
let turns = detect_orientation(&current);
if turns != 0 {
current = rotate_quarter_turns(&current, turns);
inverse = compose(&inverse, &inverse_quarter_turns(turns, w, h));
}
}
// (c) Deskew: straighten skewed lines (opt-in; no-op under 64 px, blank, or without a
// clear margin or below 0.1°).
if options.deskew
&& let Some(angle) = estimate_skew(&current)
{
let (straightened, back) = deskew_rotate(&current, angle);
current = straightened;
inverse = compose(&inverse, &back);
}
// (d) Denoise: 3×3 median with edge clamping (opt-in; dimensions never change).
if options.denoise {
current = median3(&current);
}
// (e) Binarize: Sauvola (window 8, k 0.34) via an integral image (opt-in); dark ink stays
// dark and paper stays light.
if options.binarize {
current = sauvola(&current);
}
Ok(Preprocessed { image: current, inverse_transform: inverse, dpi })
}
/// An affine map `(x, y) → (a·x + c·y + e, b·x + d·y + f)`, composed in f64 so mapping a
/// word box back is exact up to float rounding.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct InverseTransform {
a: f64,
b: f64,
c: f64,
d: f64,
e: f64,
f: f64,
}
impl InverseTransform {
pub fn identity() -> InverseTransform {
InverseTransform { a: 1.0, b: 0.0, c: 0.0, d: 1.0, e: 0.0, f: 0.0 }
}
fn scale(s: f64) -> InverseTransform {
InverseTransform { a: s, b: 0.0, c: 0.0, d: s, e: 0.0, f: 0.0 }
}
/// `compose(outer, inner)` maps `p` to `outer(inner(p))`: the accumulated inverse of the
/// earlier steps is the outer map (their spaces come later in the round trip), so each
/// step calls `compose(&inverse, &this_step_inverse)`. The scale step, for instance,
/// happens first, so its inverse is applied last: `scale⁻¹(rotate⁻¹(p))`.
fn compose_(outer: &InverseTransform, inner: &InverseTransform) -> InverseTransform {
InverseTransform {
a: outer.a * inner.a + outer.c * inner.b,
b: outer.b * inner.a + outer.d * inner.b,
c: outer.a * inner.c + outer.c * inner.d,
d: outer.b * inner.c + outer.d * inner.d,
e: outer.a * inner.e + outer.c * inner.f + outer.e,
f: outer.b * inner.e + outer.d * inner.f + outer.f,
}
}
/// Map a point from the processed image back to the input raster.
pub fn apply(&self, x: f64, y: f64) -> (f64, f64) {
(self.a * x + self.c * y + self.e, self.b * x + self.d * y + self.f)
}
/// Map a word box `[left, top, right, bottom]` back to the input raster: the four corners
/// go through [`Self::apply`] and their bounding box is rounded outward to whole pixels.
/// A box with no finite corners comes back unchanged (garbage in, garbage out — never a
/// panic).
pub fn map_rect(&self, rect: [f32; 4]) -> [f32; 4] {
let corners = [(rect[0], rect[1]), (rect[2], rect[1]), (rect[0], rect[3]), (rect[2], rect[3])];
let mut lo = (f64::INFINITY, f64::INFINITY);
let mut hi = (f64::NEG_INFINITY, f64::NEG_INFINITY);
let mut finite = false;
for (x, y) in corners {
if !(x.is_finite() && y.is_finite()) {
continue;
}
let (mx, my) = self.apply(f64::from(x), f64::from(y));
if mx.is_finite() && my.is_finite() {
finite = true;
lo = (lo.0.min(mx), lo.1.min(my));
hi = (hi.0.max(mx), hi.1.max(my));
}
}
if !finite {
return rect;
}
[lo.0.floor() as f32, lo.1.floor() as f32, hi.0.ceil() as f32, hi.1.ceil() as f32]
}
}
/// The step inverses compose in pipeline order: each new step's inverse is applied *after*
/// the inverses accumulated so far.
fn compose(outer: &InverseTransform, inner: &InverseTransform) -> InverseTransform {
InverseTransform::compose_(outer, inner)
}
/// The size `width` x `height` becomes under `scale`, refused (before any buffer exists) when
/// a side is under one pixel or over [`MAX_SIDE`], or the area is over [`MAX_PIXELS`]: two
/// sides under the side cap can still ask for hundreds of megabytes, which `OcrImage::new`
/// would only refuse after they were allocated.
fn scaled_size(width: u32, height: u32, scale: f64) -> Result<(u32, u32), OcrError> {
let new_w = (f64::from(width) * scale).ceil();
let new_h = (f64::from(height) * scale).ceil();
if !(1.0..=f64::from(MAX_SIDE)).contains(&new_w) || !(1.0..=f64::from(MAX_SIDE)).contains(&new_h) {
return Err(OcrError::ImageTooLarge);
}
let (nw, nh) = (new_w as u32, new_h as u32);
if (nw as usize).checked_mul(nh as usize).is_none_or(|pixels| pixels > MAX_PIXELS as usize) {
return Err(OcrError::ImageTooLarge);
}
Ok((nw, nh))
}
/// Bilinear resize by `scale`, clamped at the edges; the result stays inside [`scaled_size`]'s
/// limits (the side cap and the area cap).
fn resize_bilinear(image: &OcrImage, scale: f64) -> Result<OcrImage, OcrError> {
let (nw, nh) = scaled_size(image.width, image.height, scale)?;
let mut out = vec![255u8; (nw as usize).checked_mul(nh as usize).and_then(|p| p.checked_mul(4)).ok_or(OcrError::ImageTooLarge)?];
for (y, row) in out.chunks_exact_mut(4 * nw as usize).enumerate() {
for (x, px) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() {
// The source position of the destination pixel's centre, and the four
// neighbours bilinear interpolation blends (positions clamp at the edges).
let sx = (x as f64 + 0.5) / scale - 0.5;
let sy = (y as f64 + 0.5) / scale - 0.5;
let (x0, y0) = (sx.floor().max(0.0), sy.floor().max(0.0));
let (fx, fy) = (sx - x0, sy - y0);
for (ch, o) in px.iter_mut().enumerate() {
let blend = |dx: f64, dy: f64| f64::from(image.byte(x0 + dx, y0 + dy, ch));
let v = blend(0.0, 0.0) * (1.0 - fx) * (1.0 - fy)
+ blend(1.0, 0.0) * fx * (1.0 - fy)
+ blend(0.0, 1.0) * (1.0 - fx) * fy
+ blend(1.0, 1.0) * fx * fy;
*o = v.round().clamp(0.0, 255.0) as u8;
}
}
}
OcrImage::new(nw, nh, out)
}
/// Grayscale (Rec.601 luma) of every pixel.
fn grayscale(image: &OcrImage) -> Vec<u8> {
image.rgba.as_chunks::<4>().0.iter().map(|p| ((299 * p[0] as u32 + 587 * p[1] as u32 + 114 * p[2] as u32) / 1000).min(255) as u8).collect()
}
/// How the page must be turned (quarter turns, clockwise) to read upright.
type QuarterTurns = u32;
/// Detect the correction for pages scanned sideways or upside down. Method (documented,
/// panic-free, no OCR): ink is everything darker than the midpoint between the darkest and
/// lightest pixel; for each candidate correction (0/90/180/270 clockwise) the ink's row
/// profile is taken and scored by how sharply it bands (Σ Δ², the projection-profile score:
/// upright text lines band sharply, turned text does not). The best-banding axis wins when it
/// beats the other axis by a factor of 1.3; within the winning axis the line bands'
/// bottom-heaviness (real text carries more ink below each line's middle — baselines and
/// descenders) picks the quarter turn, with a 4%-of-ink margin. Anything less clear is a
/// no-op (`0`), and images under 32 px are never judged.
fn detect_orientation(image: &OcrImage) -> QuarterTurns {
if image.min_side() < 32 {
return 0;
}
let gray = grayscale(image);
let (Some(lo), Some(hi)) = (gray.iter().min(), gray.iter().max()) else { return 0 };
let t = (*lo as u32 + *hi as u32) / 2;
let ink: Vec<bool> = gray.iter().map(|&g| (g as u32) < t).collect();
let (w, h) = (image.width as usize, image.height as usize);
let total: u64 = ink.iter().filter(|i| **i).count() as u64;
if total == 0 {
return 0;
}
// The ink grid rotated by k quarter turns (clockwise), as row profiles.
let mut scores = [0.0f64; 4];
let mut asyms = [0.0f64; 4];
for k in 0u32..4 {
let k = k as usize;
let profile = rotated_row_profile(&ink, w, h, k as u32);
scores[k] = profile.windows(2).map(|p| (p[1] as f64 - p[0] as f64).powi(2)).sum();
asyms[k] = band_bottom_heaviness(&profile);
}
// 0 and 180 share one profile shape (reversed), as do 90 and 270: pick the axis first.
let (vertical_pair, horizontal_pair) = ((scores[0], scores[2]), (scores[1], scores[3]));
let row_axis = vertical_pair.0.max(vertical_pair.1);
let column_axis = horizontal_pair.0.max(horizontal_pair.1);
if row_axis >= column_axis {
if row_axis < column_axis * 1.3 {
return 0; // the axes are too close to call
}
if asyms[0] > 0.15 * total as f64 {
0
} else if asyms[2] > 0.15 * total as f64 {
2
} else {
0 // the bands are not clearly bottom-heavy either way: no-op
}
} else {
if column_axis < row_axis * 1.3 {
return 0;
}
if asyms[1] > 0.15 * total as f64 {
1
} else if asyms[3] > 0.15 * total as f64 {
3
} else {
0
}
}
}
/// The row profile of the ink grid after `k` quarter turns clockwise.
fn rotated_row_profile(ink: &[bool], w: usize, h: usize, k: u32) -> Vec<u64> {
// The rotated image is `w` pixels tall for odd k and `h` for even k; its row index is
// always the new y of the source pixel.
let len = if k.is_multiple_of(2) { h } else { w };
let mut profile = vec![0u64; len];
for (y, row) in ink.chunks_exact(w).enumerate() {
for (x, on) in row.iter().enumerate() {
if !on {
continue;
}
// Where does (x, y) land after k quarter turns clockwise?
let ny = match k {
1 => x,
2 => h - 1 - y,
3 => w - 1 - x,
_ => y,
};
if let Some(p) = profile.get_mut(ny) {
*p += 1;
}
}
}
profile
}
/// Sum over text-line bands of (ink in the band's lower half − ink in its upper half):
/// positive when the bands carry more ink near their bottoms, as real text does.
fn band_bottom_heaviness(profile: &[u64]) -> f64 {
let mut asym = 0.0;
let mut start: Option<usize> = None;
for (i, &p) in profile.iter().chain(std::iter::once(&0)).enumerate() {
match (start, p > 0) {
(None, true) => start = Some(i),
(Some(s), false) => {
let mid = (s + i) / 2;
let lower: u64 = profile.get(s..i).map_or(0, |b| b[mid.saturating_sub(s)..].iter().sum());
let upper: u64 = profile.get(s..i).map_or(0, |b| b[..mid.saturating_sub(s)].iter().sum());
asym += lower as f64 - upper as f64; // signed: a top-heavy band scores below zero
start = None;
}
_ => {}
}
}
asym
}
/// Rotate the raster `k` quarter turns clockwise (an exact pixel permutation).
fn rotate_quarter_turns(image: &OcrImage, k: QuarterTurns) -> OcrImage {
let (w, h) = (image.width as usize, image.height as usize);
let (nw, nh) = if k.is_multiple_of(2) { (image.width, image.height) } else { (image.height, image.width) };
let mut out = vec![255u8; nw as usize * nh as usize * 4];
for (y, row) in image.rgba.chunks_exact(w * 4).enumerate() {
for (x, px) in row.as_chunks::<4>().0.iter().enumerate() {
let (nx, ny) = match k {
1 => (h - 1 - y, x),
2 => (w - 1 - x, h - 1 - y),
3 => (y, w - 1 - x),
_ => (x, y),
};
if let Some(dst) = out.get_mut((ny * nw as usize + nx) * 4..(ny * nw as usize + nx) * 4 + 4) {
dst.copy_from_slice(px);
}
}
}
OcrImage::new(nw, nh, out).unwrap_or_else(|_| image.clone())
}
/// The inverse of a `k`-quarter-turn correction: where each corrected pixel came from.
fn inverse_quarter_turns(k: QuarterTurns, old_w: u32, old_h: u32) -> InverseTransform {
let (w, h) = (f64::from(old_w), f64::from(old_h));
match k {
// Forward (content moves): (x, y) ↦ (h−1−y, x) for one turn clockwise.
1 => InverseTransform { a: 0.0, b: -1.0, c: 1.0, d: 0.0, e: 0.0, f: h - 1.0 },
2 => InverseTransform { a: -1.0, b: 0.0, c: 0.0, d: -1.0, e: w - 1.0, f: h - 1.0 },
3 => InverseTransform { a: 0.0, b: 1.0, c: -1.0, d: 0.0, e: w - 1.0, f: 0.0 },
_ => InverseTransform::identity(),
}
}
/// Estimate the skew of the text lines, in degrees (−5…5, positive: lines run downhill to the
/// right). Method (documented, panic-free): for each candidate angle the ink is sheared into a
/// row profile (`r = y − (x − cx)·tan φ`); the projection-profile score (Σ profile²) peaks
/// where the shear realigns the lines. `None` — leave the page alone — when the image is under
/// 64 px on a side, blank, the winner is under 0.1°, or the winner does not clearly beat
/// leaving the page as is (score < 1.02 × the 0° score).
fn estimate_skew(image: &OcrImage) -> Option<f64> {
if image.min_side() < 64 {
return None;
}
let gray = grayscale(image);
let (Some(lo), Some(hi)) = (gray.iter().min(), gray.iter().max()) else { return None };
let t = (*lo as u32 + *hi as u32) / 2;
let ink: Vec<bool> = gray.iter().map(|&g| (g as u32) < t).collect();
if !ink.iter().any(|i| *i) {
return None; // a blank page has no skew
}
let (w, h) = (image.width, image.height);
let cx = f64::from(w) / 2.0;
let max_shift = (f64::from(w) / 2.0 * 5f64.to_radians().tan()).ceil() as i64 + 2;
let score = |phi_deg: f64| -> f64 {
let slope = phi_deg.to_radians().tan();
let mut profile = vec![0u64; (h as i64 + 2 * max_shift + 1) as usize];
for (y, row) in ink.chunks_exact(w as usize).enumerate() {
for (x, on) in row.iter().enumerate() {
if !on {
continue;
}
let r = y as f64 - (x as f64 - cx) * slope;
let idx = r.round() as i64 + max_shift;
if let Some(p) = profile.get_mut(idx as usize) {
*p += 1;
}
}
}
profile.iter().map(|p| (*p as f64).powi(2)).sum()
};
let mut best = (0.0f64, score(0.0));
let mut phi = -5.0;
while phi <= 5.0 + 1e-9 {
let s = score(phi);
if s > best.1 {
best = (phi, s);
}
phi += 0.25;
}
let (angle, best_score) = best;
if angle.abs() < 0.1 || best_score < 1.02 * score(0.0) {
return None; // straight enough, or no clear winner: no-op
}
Some(angle)
}
/// Rotate the content by `−angle` about the centre (the deskew correction), nearest-neighbour
/// with white fill; also returns the exact dest→src map (the correction's inverse).
fn deskew_rotate(image: &OcrImage, angle_deg: f64) -> (OcrImage, InverseTransform) {
let phi = angle_deg.to_radians();
let (cos, sin) = (phi.cos(), phi.sin());
let (w, h) = (image.width, image.height);
let (cx, cy) = (f64::from(w) / 2.0, f64::from(h) / 2.0);
let back = InverseTransform { a: cos, b: sin, c: -sin, d: cos, e: cx - cos * cx + sin * cy, f: cy - sin * cx - cos * cy };
let mut out = vec![255u8; w as usize * h as usize * 4];
for (y, row) in out.chunks_exact_mut(w as usize * 4).enumerate() {
for (x, px) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (sx, sy) = back.apply(x as f64, y as f64);
let (xi, yi) = (sx.round() as i64, sy.round() as i64);
if xi < 0 || yi < 0 || xi >= w as i64 || yi >= h as i64 {
continue; // outside the scan: white fill
}
if let Some(src) = image.rgba.get((yi as usize * w as usize + xi as usize) * 4..(yi as usize * w as usize + xi as usize) * 4 + 4) {
px.copy_from_slice(src);
}
}
}
(OcrImage::new(w, h, out).unwrap_or_else(|_| image.clone()), back)
}
/// 3×3 median filter on each colour channel, edges clamped (border pixels replicate);
/// dimensions and alpha are unchanged.
fn median3(image: &OcrImage) -> OcrImage {
let (w, h) = (image.width as usize, image.height as usize);
let mut out = image.rgba.clone();
for (i, dst) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let (x, y) = (i % w, i / w);
let mut rgba = [255u8; 3];
for (ch, v) in rgba.iter_mut().enumerate() {
let mut window = [0u8; 9];
for (n, (dx, dy)) in [(-1i64, -1i64), (0, -1), (1, -1), (-1, 0), (0, 0), (1, 0), (-1, 1), (0, 1), (1, 1)].iter().enumerate() {
let nx = ((x as i64) + dx).clamp(0, w as i64 - 1) as usize;
let ny = ((y as i64) + dy).clamp(0, h as i64 - 1) as usize;
window[n] = image.byte(nx as f64, ny as f64, ch);
}
window.sort_unstable();
*v = window[4];
}
let alpha = dst[3];
dst.copy_from_slice(&[rgba[0], rgba[1], rgba[2], alpha]);
}
OcrImage::new(image.width, image.height, out).unwrap_or_else(|_| image.clone())
}
/// The Sauvola ink mask: local thresholding with the documented window — 8 pixels wide, the
/// 8 pixels from −4 to +3 (the end is exclusive, so `x0..x1` is exactly `x−4..=x+3`), k 0.34 —
/// through an integral image. A pixel is ink when its grey is at or under the local
/// threshold.
fn sauvola_mask(image: &OcrImage) -> Vec<bool> {
let (w, h) = (image.width as usize, image.height as usize);
let gray = grayscale(image);
// Integral sums and square sums, (w+1)×(h+1), so any window sum is four lookups.
let stride = w + 1;
let mut sums = vec![0u64; stride * (h + 1)];
let mut squares = vec![0u64; stride * (h + 1)];
for (y, row) in gray.chunks_exact(w).enumerate() {
let (mut row_sum, mut row_sq) = (0u64, 0u64);
for (x, &g) in row.iter().enumerate() {
row_sum += u64::from(g);
row_sq += u64::from(g) * u64::from(g);
let above = sums.get(y * stride + x + 1).copied().unwrap_or(0);
let above_sq = squares.get(y * stride + x + 1).copied().unwrap_or(0);
if let (Some(s), Some(sq)) = (sums.get_mut((y + 1) * stride + x + 1), squares.get_mut((y + 1) * stride + x + 1)) {
*s = above + row_sum;
*sq = above_sq + row_sq;
}
}
}
let (k, r) = (0.34f64, 128.0f64);
let half = 4usize;
let mut ink = vec![false; w * h];
for (y, row) in ink.chunks_exact_mut(w).enumerate() {
for (x, dst) in row.iter_mut().enumerate() {
let (x0, y0) = (x.saturating_sub(half), y.saturating_sub(half));
// Exclusive end: the window covers −4..=+3, 8 pixels on a side (an end of
// `x + half + 1` would make it 9 and disagree with the documented window).
let (x1, y1) = ((x + half).min(w), (y + half).min(h));
let n = ((x1 - x0) * (y1 - y0)) as f64;
let sum = rect_sum(&sums, stride, x0, y0, x1, y1);
let sq = rect_sum(&squares, stride, x0, y0, x1, y1);
let mean = sum / n;
let var = (sq / n - mean * mean).max(0.0);
let threshold = mean * (1.0 + k * (var.sqrt() / r - 1.0));
*dst = matches!(gray.get(y * w + x), Some(&g) if f64::from(g) <= threshold);
}
}
ink
}
/// Sauvola binarization through [`sauvola_mask`], followed by a one-pixel dilation of the ink:
/// ink goes to black, paper to white, alpha preserved.
fn sauvola(image: &OcrImage) -> OcrImage {
let (w, h) = (image.width as usize, image.height as usize);
// One dilation keeps anti-aliased strokes whole: a thresholded edge pixel that flaked off
// rejoins its stroke (slightly bold beats broken, for reading).
let ink = dilate1(&sauvola_mask(image), w, h);
let mut out = image.rgba.clone();
for (i, px) in out.as_chunks_mut::<4>().0.iter_mut().enumerate() {
let v = if ink.get(i).copied().unwrap_or(false) { 0u8 } else { 255u8 };
if let Some(dst) = px.get_mut(..3) {
dst.copy_from_slice(&[v, v, v]);
}
}
OcrImage::new(image.width, image.height, out).unwrap_or_else(|_| image.clone())
}
/// Grow the ink mask by one pixel (8-neighbourhood, edges clamped by the bounds checks).
fn dilate1(ink: &[bool], w: usize, h: usize) -> Vec<bool> {
let mut out = ink.to_vec();
for y in 0..h {
for x in 0..w {
if !ink.get(y * w + x).copied().unwrap_or(false) {
continue;
}
for dy in [-1isize, 0, 1] {
for dx in [-1isize, 0, 1] {
let (nx, ny) = (x as isize + dx, y as isize + dy);
if nx < 0 || ny < 0 || nx >= w as isize || ny >= h as isize {
continue;
}
if let Some(o) = out.get_mut(ny as usize * w + nx as usize) {
*o = true;
}
}
}
}
}
out
}
fn rect_sum(table: &[u64], stride: usize, x0: usize, y0: usize, x1: usize, y1: usize) -> f64 {
let a = table.get(y1 * stride + x1).copied().unwrap_or(0);
let b = table.get(y0 * stride + x1).copied().unwrap_or(0);
let c = table.get(y1 * stride + x0).copied().unwrap_or(0);
let d = table.get(y0 * stride + x0).copied().unwrap_or(0);
(a + d - b - c) as f64
}
#[cfg(test)]
mod tests {
use super::*;
/// Sides under MAX_SIDE whose area passes MAX_PIXELS (9500 × 9500, 90 MP) are refused
/// when the size is worked out, before the 360 MB buffer is allocated rather than after.
#[test]
fn an_upscale_over_the_area_cap_is_refused_before_allocating() {
assert!(matches!(scaled_size(1000, 1000, 9.5), Err(OcrError::ImageTooLarge)));
assert_eq!(scaled_size(1000, 1000, 2.0).unwrap(), (2000, 2000), "under the caps it still scales");
assert_eq!(scaled_size(8000, 8000, 1.0).unwrap(), (8000, 8000), "exactly 64 MP is allowed");
assert!(matches!(scaled_size(8001, 8000, 1.0), Err(OcrError::ImageTooLarge)), "one row over is not");
assert!(matches!(scaled_size(1000, 1000, 10.5), Err(OcrError::ImageTooLarge)), "a side over MAX_SIDE");
assert!(matches!(scaled_size(1000, 1000, 0.0), Err(OcrError::ImageTooLarge)), "a side under one pixel");
assert!(matches!(scaled_size(1000, 1000, f64::NAN), Err(OcrError::ImageTooLarge)));
let small = OcrImage::new(100, 100, vec![255; 100 * 100 * 4]).unwrap();
assert!(matches!(resize_bilinear(&small, 100.5), Err(OcrError::ImageTooLarge)));
assert_eq!(resize_bilinear(&small, 2.0).unwrap().width, 200);
}
fn white(w: u32, h: u32) -> OcrImage {
OcrImage::new(w, h, vec![255; (w * h * 4) as usize]).unwrap()
}
/// Ink bounding box of a raster (None when blank).
fn ink_bbox(image: &OcrImage) -> Option<[f32; 4]> {
let gray = grayscale(image);
let (Some(lo), Some(hi)) = (gray.iter().min(), gray.iter().max()) else { return None };
let t = (*lo as u32 + *hi as u32) / 2;
let (mut l, mut tp, mut r, mut b) = (u32::MAX, u32::MAX, 0u32, 0u32);
let mut any = false;
for (i, &g) in gray.iter().enumerate() {
if (g as u32) >= t {
continue;
}
any = true;
let (x, y) = ((i as u32) % image.width, (i as u32) / image.width);
l = l.min(x);
r = r.max(x);
tp = tp.min(y);
b = b.max(y);
}
any.then_some([l as f32, tp as f32, r as f32, b as f32])
}
/// A horizontal line of three bottom-heavy block glyphs (L shapes) on white — the fixture
/// for orientation detection (240 × 64 at 300 dpi).
fn l_line() -> OcrImage {
let (w, h) = (240u32, 64u32);
let mut img = white(w, h);
for gx in [20u32, 100, 180] {
fill(&mut img, gx, 12, 12, 40);
fill(&mut img, gx, 40, 36, 12);
}
img
}
fn fill(img: &mut OcrImage, x0: u32, y0: u32, w: u32, h: u32) {
for y in y0..y0 + h {
for x in x0..x0 + w {
let o = ((y * img.width + x) * 4) as usize;
if let Some(p) = img.rgba.get_mut(o..o + 3) {
p.copy_from_slice(&[0, 0, 0]);
}
}
}
}
/// The same fixture pre-rotated by k quarter turns clockwise, built independently of the
/// crate's rotation (so the test cannot pass by a shared bug).
fn rotated(k: u32) -> OcrImage {
let src = l_line();
let (w, h) = (src.width as usize, src.height as usize);
let (nw, nh) = if k.is_multiple_of(2) { (src.width, src.height) } else { (src.height, src.width) };
let mut out = white(nw, nh);
for (y, row) in src.rgba.chunks_exact(w * 4).enumerate() {
for (x, px) in row.as_chunks::<4>().0.iter().enumerate() {
if px[0] != 0 {
continue;
}
let (nx, ny) = match k {
1 => (h - 1 - y, x),
2 => (w - 1 - x, h - 1 - y),
3 => (y, w - 1 - x),
_ => (x, y),
};
fill(&mut out, nx as u32, ny as u32, 1, 1);
}
}
out
}
/// A ruled fixture sheared by `degrees` (lines running downhill to the right for positive
/// angles): four 8-px bars across a 300 × 120 page.
fn sheared(degrees: f64) -> OcrImage {
let (w, h) = (300u32, 120u32);
let mut img = white(w, h);
let slope = degrees.to_radians().tan();
for (base, height) in [(20u32, 8u32), (45, 8), (70, 8), (95, 8)] {
for x in 20..280u32 {
let y = (base as f64 + (x as f64 - 150.0) * slope).round().clamp(0.0, f64::from(h - height - 1)) as u32;
fill(&mut img, x, y, 1, height);
}
}
img
}
/// With the four opt-in flags off and a dpi at or above 300, the raster passes through
/// untouched and the inverse transform is exact.
#[test]
fn flags_off_and_adequate_dpi_is_an_identity_pass_through() {
let src = l_line();
let p = preprocess(src.clone(), 300.0, &PreprocessOptions::default()).unwrap();
assert_eq!(p.image.rgba, src.rgba);
assert_eq!(p.dpi, 300.0);
assert_eq!(p.inverse_transform, InverseTransform::identity());
assert_eq!(p.inverse_transform.map_rect([3.0, 7.0, 41.0, 19.0]), [3.0, 7.0, 41.0, 19.0]);
}
/// With a step on, a scan below 300 dpi is enlarged by 300/src first, and its boxes map
/// back to the original raster.
#[test]
fn low_dpi_scans_are_upscaled_for_the_steps_and_boxes_map_back() {
let src = white(100, 50);
let on = PreprocessOptions { denoise: true, ..Default::default() };
let p = preprocess(src.clone(), 150.0, &on).unwrap();
assert_eq!((p.image.width, p.image.height), (200, 100));
assert_eq!(p.dpi, 300.0);
assert_eq!(p.inverse_transform.map_rect([0.0, 0.0, 200.0, 100.0]), [0.0, 0.0, 100.0, 50.0]);
assert_eq!(p.inverse_transform.apply(2.0, 4.0), (1.0, 2.0));
// 300 and 600 dpi are not resampled; a broken dpi is left alone.
assert_eq!(preprocess(src.clone(), 600.0, &on).unwrap().image.width, 100);
assert_eq!(preprocess(src, f32::NAN, &on).unwrap().image.width, 100);
}
/// With every step off a low-dpi render is read exactly as rendered: not resampled, with
/// the identity as its way back. (Reading a 72 dpi scan through a 4x enlargement changed
/// what the models read: "due: \€250" came back as "ue: ?250".)
#[test]
fn with_every_step_off_a_low_dpi_render_is_not_resampled() {
let src = l_line();
let p = preprocess(src.clone(), 72.0, &PreprocessOptions::default()).unwrap();
assert_eq!(p.image.rgba, src.rgba);
assert_eq!((p.image.width, p.image.height), (src.width, src.height));
assert_eq!(p.dpi, 72.0, "the render's own resolution");
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// The upscale refuses to break the side cap.
#[test]
fn upscaling_respects_the_side_cap() {
let src = white(9999, 10);
let r = preprocess(src, 72.0, &PreprocessOptions { denoise: true, ..Default::default() });
assert!(matches!(r, Err(OcrError::ImageTooLarge)), "upscale past the cap: {r:?}");
}
/// An upright page is left alone by the orientation detector.
#[test]
fn upright_pages_are_not_rotated() {
let p = preprocess(l_line(), 300.0, &PreprocessOptions { auto_rotate: true, ..Default::default() }).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// Pages scanned at 90/180/270 are detected, turned upright, and their word boxes map back
/// onto the scanned raster's ink.
#[test]
fn quarter_turns_are_detected_corrected_and_mapped_back() {
for k in 1..=3 {
let scanned = rotated(k);
let p = preprocess(scanned.clone(), 300.0, &PreprocessOptions { auto_rotate: true, ..Default::default() }).unwrap();
let corrected = ink_bbox(&p.image).unwrap_or_else(|| panic!("k={k}: the corrected page still has ink"));
// The corrected reading is a horizontal line again: wider than tall.
assert!(corrected[2] - corrected[0] > corrected[3] - corrected[1], "k={k}: {corrected:?}");
// And the box maps back onto the scanned raster's own ink box.
let back = p.inverse_transform.map_rect(corrected);
let original = ink_bbox(&scanned).unwrap();
for i in 0..4 {
assert!((back[i] - original[i]).abs() <= 2.0, "k={k}: {back:?} vs {original:?}");
}
}
}
/// Images under 32 px on a side are never judged; the step is a no-op.
#[test]
fn tiny_images_skip_orientation() {
let src = white(31, 31);
let p = preprocess(src, 300.0, &PreprocessOptions { auto_rotate: true, ..Default::default() }).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// Blank pages are inconclusive: every step leaves them alone.
#[test]
fn blank_pages_are_a_noop() {
let src = white(200, 200);
let p = preprocess(src, 300.0, &PreprocessOptions { auto_rotate: true, deskew: true, denoise: true, binarize: true }).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// The skew estimator sees a 3° shear (either direction) and leaves a straight page alone.
#[test]
fn skew_is_estimated_and_straight_pages_are_left_alone() {
for degrees in [3.0, -3.0] {
let angle = estimate_skew(&sheared(degrees)).unwrap_or_else(|| panic!("{degrees}° should be found"));
assert!((angle - degrees).abs() <= 0.5, "{degrees}°: got {angle}");
}
assert!(estimate_skew(&sheared(0.0)).is_none(), "a straight page is a no-op");
assert!(estimate_skew(&l_line()).is_none(), "glyph lines without a shear are a no-op");
}
/// Deskew straightens ±3° lines (the vertical span tightens) and the centre maps back to
/// itself.
#[test]
fn deskew_corrects_plus_and_minus_three_degrees() {
for degrees in [3.0, -3.0] {
let src = sheared(degrees);
let before = ink_bbox(&src).unwrap();
let p = preprocess(src.clone(), 300.0, &PreprocessOptions { deskew: true, ..Default::default() }).unwrap();
let after = ink_bbox(&p.image).unwrap();
assert!(after[3] - after[1] < before[3] - before[1], "{degrees}°: {before:?} → {after:?}");
// The exact inverse maps the corrected centre back to the scanned centre.
let (cx, cy) = (f64::from(src.width) / 2.0, f64::from(src.height) / 2.0);
let (bx, by) = p.inverse_transform.apply(cx, cy);
assert!((bx - cx).abs() < 1.0 && (by - cy).abs() < 1.0, "{degrees}°: ({bx}, {by})");
}
}
/// Deskew no-ops on small images (under 64 px) — the detector is never consulted.
#[test]
fn small_images_skip_deskew() {
let src = white(63, 63);
let p = preprocess(src, 300.0, &PreprocessOptions { deskew: true, ..Default::default() }).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// The median filter removes isolated specks, keeps solid strokes, and never changes the
/// dimensions.
#[test]
fn denoise_removes_specks_and_keeps_dimensions() {
let mut src = white(32, 32);
fill(&mut src, 15, 15, 1, 1); // salt
fill(&mut src, 4, 4, 5, 5); // a solid stroke
let p = preprocess(src, 300.0, &PreprocessOptions { denoise: true, ..Default::default() }).unwrap();
assert_eq!((p.image.width, p.image.height), (32, 32));
assert_eq!(p.image.byte(15.0, 15.0, 0), 255, "an isolated speck is gone");
assert_eq!(p.image.byte(6.0, 6.0, 0), 0, "the inside of a solid block stays");
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// Sauvola keeps the polarity (dark ink stays dark, paper stays light) and the metadata.
/// The fixture is a thin grey stroke: its windows mix ink and paper, so the local
/// threshold separates them (a perfectly flat mid-tone area has no contrast and whitens,
/// which is Sauvola working as designed).
#[test]
fn binarize_keeps_polarity_and_metadata() {
let (w, h) = (64u32, 64u32);
let mut src = white(w, h);
for y in 26..30 {
for x in 8..56 {
let o = ((y * w + x) * 4) as usize;
if let Some(p) = src.rgba.get_mut(o..o + 3) {
p.copy_from_slice(&[70, 70, 70]); // a grey scan's ink, far below paper
}
}
}
let p = preprocess(src, 300.0, &PreprocessOptions { binarize: true, ..Default::default() }).unwrap();
assert_eq!((p.image.width, p.image.height), (w, h));
assert_eq!(p.dpi, 300.0);
assert_eq!(p.image.byte(30.0, 28.0, 0), 0, "ink stays dark");
assert_eq!(p.image.byte(2.0, 2.0, 0), 255, "paper stays light");
for px in p.image.rgba.as_chunks::<4>().0.iter() {
assert!((px[0] == 0 || px[0] == 255) && px[0] == px[1] && px[1] == px[2], "{px:?}");
}
}
/// The inverse transform composes: an upscaled, turned, deskewed page maps corrected
/// points back inside the original raster.
#[test]
fn inverse_transform_maps_points_back_through_every_step() {
let scanned = rotated(1); // a page photographed on its side
let p = preprocess(scanned.clone(), 150.0, &PreprocessOptions { auto_rotate: true, deskew: true, ..Default::default() }).unwrap();
let (w, h) = (f64::from(p.image.width), f64::from(p.image.height));
for (x, y) in [(0.0, 0.0), (w - 1.0, 0.0), (0.0, h - 1.0), (w - 1.0, h - 1.0), (w / 2.0, h / 2.0)] {
let (bx, by) = p.inverse_transform.apply(x, y);
assert!((-2.0..=f64::from(scanned.width) + 2.0).contains(&bx), "x {x} → {bx}");
assert!((-2.0..=f64::from(scanned.height) + 2.0).contains(&by), "y {y} → {by}");
}
// The scale step, alone, is exact.
assert_eq!(InverseTransform::scale(0.5).apply(4.0, 10.0), (2.0, 5.0));
}
/// A realistic letter page (one line of type on white, at 300 dpi) reads as upright: the
/// row axis bands decisively sharper than the column axis, and the near-balanced
/// bottom-heaviness of real mixed-case text is below the 15% flip bar, so the page is left
/// alone rather than risk flipping a good scan upside down.
#[test]
fn realistic_letter_pages_are_left_upright() {
let (w, h) = (1200u32, 1650u32);
let mut img = white(w, h);
let mut x = 120u32;
let base = 300u32;
let mut seed = 7u64;
let mut rnd = || {
seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
(seed >> 33) as u32
};
while x < 1000 {
let word = rnd() % 5 + 2;
for _ in 0..word {
let kind = rnd() % 4;
let wl = rnd() % 8 + 3;
match kind {
0 => fill(&mut img, x, base - 20, wl, 20), // x-height
1 => fill(&mut img, x, base - 34, wl, 34), // ascender
2 => fill(&mut img, x, base - 20, wl, 28), // descender
_ => {
fill(&mut img, x, base - 20, 2, 20);
fill(&mut img, x + 3, base - 14, 2, 14); // stems
}
}
x += wl + 2;
}
x += 12; // space
}
assert_eq!(detect_orientation(&img), 0);
let p = preprocess(img, 300.0, &PreprocessOptions { auto_rotate: true, ..Default::default() }).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity());
}
/// Hostile inputs are errors or no-ops, never panics: 1×1 rasters, all-black pages, NaN
/// boxes through the inverse map.
#[test]
fn hostile_inputs_never_panic() {
let tiny = white(1, 1);
let all = PreprocessOptions { auto_rotate: true, deskew: true, denoise: true, binarize: true };
let p = preprocess(tiny, 300.0, &all).unwrap();
assert_eq!((p.image.width, p.image.height), (1, 1));
// All-black: no paper, nothing to judge.
let mut black = white(80, 80);
for px in black.rgba.as_chunks_mut::<4>().0 {
px.copy_from_slice(&[0, 0, 0, 255]);
}
let p = preprocess(black, 300.0, &all).unwrap();
assert_eq!(p.inverse_transform, InverseTransform::identity(), "all-black is inconclusive");
// NaN boxes never poison downstream math: the finite corners bound the mapped box,
// and a box with no finite corners comes back unchanged.
let mapped = p.inverse_transform.map_rect([f32::NAN, 0.0, 5.0, 5.0]);
assert!(mapped.iter().all(|v| v.is_finite()), "{mapped:?}");
let unchanged = InverseTransform::identity().map_rect([f32::NAN; 4]);
assert!(unchanged.iter().all(|v| v.is_nan()), "{unchanged:?}");
}
/// The one-pixel dilation is load-bearing, pinned with a fixture that fragments without
/// it: a thin anti-aliased-looking stroke on a grey scan (alternating dark and light
/// pixels) — the raw Sauvola mask drops the light runs (the local window sits on a grey
/// page, so the threshold falls below them), while the binarized image keeps the stroke
/// whole.
#[test]
fn binarize_without_the_dilation_the_stroke_fragments_and_with_it_stays_whole() {
let (w, h) = (64u32, 64u32);
let mut src = white(w, h);
let set = |img: &mut OcrImage, x: u32, y: u32, v: u8| {
let o = ((y * w + x) * 4) as usize;
if let Some(p) = img.rgba.get_mut(o..o + 3) {
p.copy_from_slice(&[v, v, v]);
}
};
for px in src.rgba.as_chunks_mut::<4>().0.iter_mut() {
px[..3].copy_from_slice(&[210, 210, 210]); // a grey scan's paper
}
// The stroke: one pixel tall, alternating dark (reads as ink) and light (an
// anti-aliased shoulder the threshold alone drops) in runs of two.
for x in 8..56u32 {
let v = if (x / 2) % 2 == 0 { 110 } else { 170 };
set(&mut src, x, 32, v);
}
let mask = sauvola_mask(&src);
let at = |x: u32, y: u32| mask[y as usize * w as usize + x as usize];
let dropped: Vec<u32> = (12..52).filter(|&x| !at(x, 32)).collect();
assert!(!dropped.is_empty(), "without the dilation the stroke fragments: these stroke pixels fall under the threshold at {dropped:?}");
let p = preprocess(src, 300.0, &PreprocessOptions { binarize: true, ..Default::default() }).unwrap();
// With the dilation the stroke is whole: every position on it has ink within its
// 8-neighbourhood.
for x in 12..=51u32 {
let ink_near = (-1i32..=1).any(|dy| {
(-1i32..=1).any(|dx| {
let (nx, ny) = (x as i32 + dx, 32 + dy);
p.image.byte(f64::from(nx), f64::from(ny), 0) == 0
})
});
assert!(ink_near, "the stroke is broken at x={x}");
}
}
/// The deskew inverse, checked at OFF-CENTRE points (the centre maps to itself under any
/// rotation, so it cannot catch a wrong sign): a point on a straightened line maps back
/// onto the sheared source line, uphill and downhill.
#[test]
fn deskew_inverse_maps_off_centre_points_back_onto_the_source_line() {
for degrees in [3.0, -3.0] {
let src = sheared(degrees);
let p = preprocess(src, 300.0, &PreprocessOptions { deskew: true, ..Default::default() }).unwrap();
let slope = degrees.to_radians().tan();
// The corrected first line sits at y = 20 (the fixture's base); the same ink was
// at 20 + (x − 150)·slope in the source. Well off centre on purpose. The
// rotation moves x a little too (it mixes in y: at most sin(5°)·40 ≈ 3.5 px
// here), so the line is checked at the mapped x — a wrong sign still misses it
// by ±(x−150)·slope, 6+ px at these offsets.
for x in [30.0f64, 150.0, 270.0] {
let (sx, sy) = p.inverse_transform.apply(x, 20.0);
assert!((sx - x).abs() < 4.0, "{degrees}° x={x}: mapped to ({sx}, {sy})");
let expected = 20.0 + (sx - 150.0) * slope;
assert!((sy - expected).abs() < 2.0, "{degrees}° x={x}: ({sx}, {sy}), the source line is at {expected}");
}
}
}
}
File diff suppressed because it is too large. Load diff
+35
View File
@@ -3953,6 +3953,41 @@ fn a_reply_on_another_page_to_a_removed_popup_goes_too() {
}
}
/// An invisible OCR-style text layer drawn in a Type3 font (what the OCR output writes for
/// characters beyond WinAnsi) is redacted like any other text: its glyphs are hit-tested by
/// their real advances, removed, and the verifier passes.
#[test]
fn a_type3_text_layer_is_redacted_like_any_other() {
// One glyph (code 1): a filled box, 500 units wide, with a ToUnicode CMap that reads it
// back as U+3042.
let t3 = b"<< /Type /Font /Subtype /Type3 /Name /PCOcr /FontBBox [0 0 700 700] /FontMatrix [0.001 0 0 0.001 0 0] \
/FirstChar 1 /LastChar 1 /Widths [500] /Encoding << /Type /Encoding /Differences [1 /g01] >> \
/CharProcs << /g01 8 0 R >> /ToUnicode 9 0 R >>";
let charproc = b"500 0 0 0 0 700 700 d1\n0 0 700 700 re f\n";
let cmap = b"/CIDInit /ProcSet findresource begin\n12 dict begin\nbegincmap\n/CMapType 2 def\n1 begincodespacerange\n<01> <01>\nendcodespacerange\n1 beginbfchar\n<01> <3042>\nendbfchar\nendcmap\nCMapName currentdict /CMap defineresource pop\nend\nend\n";
let layer = b"BT 3 Tr /T3 10 Tf 100 100 Td (\x01) Tj ET";
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /T3 5 0 R >> >> >>".to_vec(),
stream("", layer),
t3.to_vec(),
FONT.replace("95 0 R", "6 0 R").into_bytes(),
widths(),
stream("", charproc),
stream("", cmap),
]);
// The glyph box at size 10: 5 pt wide, from the font's descent to its ascent.
assert_eq!(under(&mut doc, 0, &[[98.0, 96.0, 112.0, 110.0]]), 1, "the Type3 glyph is found under the mark");
assert_eq!(under(&mut doc, 0, &[[200.0, 200.0, 250.0, 250.0]]), 0, "elsewhere the page is clean");
mark(&mut doc, 0, &[[98.0, 96.0, 112.0, 110.0]], "");
let report = apply(&mut doc, None).unwrap();
assert_eq!(report.glyphs, 1, "{report:?}");
// The verified apply left no text-showing operator: the re-read page is empty of them.
assert!(!content(&doc, 0).contains("Tj"), "{}", content(&doc, 0));
assert_eq!(under(&mut doc, 0, &[[98.0, 96.0, 112.0, 110.0]]), 0, "nothing is left under the mark");
}
#[test]
fn a_rewritten_form_xobject_drops_the_source_text_of_added_content() {
// A form drawing invisible text whose dictionary repeats it in /PCAdded.
+1
View File
@@ -23,6 +23,7 @@ pdfcraft-fonts = { workspace = true }
pdfcraft-engine = { workspace = true }
pdfcraft-cos = { workspace = true }
pdfcraft-content = { workspace = true }
pdfcraft-ocr = { workspace = true }
pdfcraft-render = { workspace = true }
pdfcraft-model = { workspace = true }
pdfcraft-platform = { workspace = true }
+54 -1
View File
@@ -6,18 +6,64 @@
//! `--width N` downscales the image to N pixels wide (README screenshots). Any other
//! `--key value` pair is passed to `PdfCraftApp::set_option`
//! (the same verbs as the desktop app's command-line flags and the future control channel).
//! `--ocr-verify on` ignores any file and instead builds a synthetic scanned page, recognizes
//! it with the real engines (models required: `cargo xtask models`) and opens the OCR Verify
//! screen on the result, as a reviewer sees it.
use std::time::{Duration, Instant};
use egui_kittest::Harness;
use pdfcraft_engine::ocr::{MergeStrategy, OcrSettings};
use pdfcraft_engine::{Session, export};
use pdfcraft_ui_egui::PdfCraftApp;
/// Build a one-page PDF that is only a picture of a sentence (as a scanner would produce),
/// recognize it with the engines named in `settings` and open the OCR Verify screen on the
/// result — the same path the review tests use, with a real recognition behind it. The toolbar
/// dropdowns are seeded to the ensemble the shot ran.
fn load_ocr_verify(app: &mut PdfCraftApp) -> Result<(), String> {
let mut s = Session::new();
let text = s
.create_from_text(
"t",
"Scanned pages are pictures of words.\nThe Verify screen reads them back before anything is applied.\nReview the uncertain words, then Accept.",
)
.map_err(|e| e.to_string())?;
let id = s.open("text.pdf", None, text, None).map_err(|e| e.to_string())?;
let png = export::Exporter::new(s.get(id).ok_or("the document is gone")?).png(0, 150.0).map_err(|e| e.to_string())?;
// Keep the text block of the rendered page, so the scan is a short note-sized picture:
// the whole page and the confidence legend fit in the shot's source-page pane.
let mut page = image::load_from_memory(&png).map_err(|e| e.to_string())?;
let mut scan_png = std::io::Cursor::new(Vec::new());
page.crop(0, 0, page.width(), (page.height() as f32 * 0.34) as u32)
.write_to(&mut scan_png, image::ImageFormat::Png)
.map_err(|e| e.to_string())?;
let scan = s.create_from_images(&[("scan.png".into(), scan_png.into_inner())]).map_err(|e| e.to_string())?.to_vec();
let settings =
OcrSettings { engine: pdfcraft_engine::ocr::EngineChoice::Auto, strategy: MergeStrategy::ConfidenceWeighted, ..OcrSettings::default() };
app.ocr_draft.engine = settings.engine;
app.ocr_draft.strategy = settings.strategy;
app.open_bytes("scan.pdf", None, scan).map_err(|e| e.to_string())?;
app.run_inline = true;
if !app.execute("ocr.correct") {
return Err("ocr.correct did not run".into());
}
let Some((_, id)) = app.active_ids() else { return Err("no open document".into()) };
let job = app.session.ocr_job(id, &[], settings.clone()).ok_or("the OCR job could not be captured")?;
let recognizers = pdfcraft_engine::ocr::recognizers(&settings)?;
let guard = job.guard.clone();
let pages = job.run(&recognizers, |_, _| true);
app.ocr_verify.load(id, guard, pages);
Ok(())
}
fn main() -> Result<(), String> {
let mut args = std::env::args().skip(1);
let out = args.next().ok_or("usage: shot <out.png> [file.pdf] [--option value …]")?;
let (mut file, mut opts) = (None, Vec::new());
let (mut size, mut scale) = (egui::vec2(1440.0, 900.0), 2.0f32);
let mut width: Option<u32> = None;
let mut ocr_verify = false;
while let Some(a) = args.next() {
match a.strip_prefix("--") {
Some("size") => {
@@ -27,13 +73,20 @@ fn main() -> Result<(), String> {
}
Some("scale") => scale = args.next().and_then(|v| v.parse().ok()).ok_or("bad --scale")?,
Some("width") => width = Some(args.next().and_then(|v| v.parse().ok()).ok_or("bad --width")?),
Some("ocr-verify") => ocr_verify = args.next().is_some_and(|v| v == "on"),
Some(k) => opts.push((k.to_string(), args.next().unwrap_or_default())),
None => file = Some(a),
}
}
let mut harness = Harness::builder().with_size(size).with_pixels_per_point(scale).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
if let Some(f) = &file {
if ocr_verify {
if let Err(e) = load_ocr_verify(&mut app) {
eprintln!("shot: --ocr-verify: {e}");
// an empty Verify screen must not pass for the documentation's screenshot
std::process::exit(1);
}
} else if let Some(f) = &file {
let bytes = std::fs::read(f).unwrap_or_default();
let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
if let Err(e) = app.open_bytes(&name, Some(f.clone()), bytes) {
+23
View File
@@ -202,6 +202,7 @@ impl PdfCraftApp {
ocr_draft: ocr_ui::OcrDraft::default(),
ocr_run: None,
ocr_batch: None,
ocr_verify: ocr_verify::OcrVerify::default(),
run_inline: false,
custom_actions: Vec::new(),
wizard: Default::default(),
@@ -624,6 +625,14 @@ impl eframe::App for PdfCraftApp {
self.poll_optimize();
self.poll_scan_clean();
self.poll_action();
// The OCR Verify screen's worker may have finished while no frame was drawn.
if self.ocr_verify.phase == workflows::ocr_verify::Phase::Running {
workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll);
}
// 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);
}
self.process_file_requests();
#[cfg(not(target_arch = "wasm32"))]
self.process_picked();
@@ -680,6 +689,20 @@ impl eframe::App for PdfCraftApp {
if self.draws_window_frame() {
window_controls::resize_edges(&ctx);
}
if self.ocr_verify.open {
// The OCR Verify screen owns the window (its four panes); dialogs and notices
// still show above it.
workflows::ocr_verify::show(self, ui);
dialogs::show(self, &ctx);
self.show_progress(&ctx);
widgets::toast(self, &ctx);
self.finish_render_frame(&ctx);
if let Some(mut control) = self.control.take() {
control.finish_frame(&ctx, self);
self.control = Some(control);
}
return;
}
chrome::tab_strip(self, ui);
chrome::mode_bar(self, ui);
// Whichever way another tab came to the front, it shows instead of the expanded view.
+4
View File
@@ -144,6 +144,7 @@ impl PdfCraftApp {
"combine_zoom": self.combine_zoom,
"comments_panel_closed": self.comments_panel_closed,
"right_panel_closed": self.right_panel_closed,
"ocr": self.ocr_draft.to_pref(),
});
// Set apart: one more key inside the macro above passes serde_json's recursion limit.
v["default_page_gaps"] = serde_json::Value::Bool(self.view_defaults.show_page_gaps);
@@ -295,6 +296,9 @@ impl PdfCraftApp {
if let Some(closed) = v["right_panel_closed"].as_bool() {
self.right_panel_closed = closed;
}
// Scan & OCR choices (language, engine, strategy, output mode, preprocessing and skip
// flags); unknown or malformed values keep the defaults.
self.ocr_draft.apply_pref(&v["ocr"]);
if let Some(defaults) = v["default_zoom"].as_str().and_then(|zoom| self.view_defaults.with_zoom(zoom)) {
self.view_defaults = defaults;
}
+1
View File
@@ -527,6 +527,7 @@ impl PdfCraftApp {
"ocr.recognize" => self.dialog = Some(Dialog::RecognizeText),
"ocr.enhance" => self.dialog = Some(Dialog::EnhanceScan),
"ocr.recognize_batch" => self.ocr_files_dialog(),
"ocr.correct" => self.ocr_verify.open = true,
"edit.edit_text" => {
self.quick_tool = crate::QuickTool::EditText;
self.left = crate::LeftPanel::Tool("edit");
+5 -1
View File
@@ -69,8 +69,9 @@ use pdfcraft_engine::{DocId, Session};
pub use canvas::{DocView, RasterMemory};
pub use editing::{CloseRequest, SaveTarget};
pub use files::{ExtractDraft, FilePurpose, FileRequest, RotateDraft, SplitDraft, SplitMode, SplitPlan};
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore};
pub use recovery::{AUTOSAVE_SECS, RecoveryMeta, RecoveryStore, default_dictionary_dir};
use theme::{ThemeKind, ThemePreference};
pub use workflows::ocr_verify::{OcrVerify, Phase};
mod app;
mod hooks;
@@ -181,6 +182,7 @@ use workflows::export_ui;
use workflows::files;
use workflows::js_ui;
use workflows::ocr_ui;
use workflows::ocr_verify;
use workflows::optimize_ui;
use workflows::print_ui;
use workflows::scan_clean_ui;
@@ -510,6 +512,8 @@ 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>>>,
/// 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).
pub run_inline: bool,
/// Action Wizard: the user's actions, the dialog state, the running action and (tests) the
+20
View File
@@ -184,7 +184,27 @@ impl RecoveryStore {
pub fn dir(&self) -> &Path {
&self.dir
}
}
/// The OCR Verify screen's per-language user dictionary folder, next to the recovery store:
/// the portable data folder's `Dictionary` in portable mode ([`crate::portable`]), otherwise
/// `…/PdfCraft/Dictionary` in the platform's per-user data folder. `None` where files are not
/// kept (the web build).
pub fn default_dictionary_dir() -> Option<PathBuf> {
if let Some(dir) = crate::portable::data_dir() {
return Some(dir.join("Dictionary"));
}
let env = |k: &str| std::env::var_os(k).filter(|v| !v.is_empty()).map(PathBuf::from);
if cfg!(target_os = "macos") {
env("HOME").map(|h| h.join("Library/Application Support/PdfCraft/Dictionary"))
} else if cfg!(windows) {
env("LOCALAPPDATA").map(|d| d.join("PdfCraft").join("Dictionary"))
} else {
env("XDG_DATA_HOME").or_else(|| env("HOME").map(|h| h.join(".local/share"))).map(|d| d.join("pdfcraft/dictionary"))
}
}
impl RecoveryStore {
/// Write (or replace) an entry. The PDF is written before its metadata, both atomically, so
/// a listed entry always has complete bytes.
pub fn write(&self, meta: &RecoveryMeta, bytes: &[u8]) -> std::io::Result<()> {
+9 -1
View File
@@ -477,6 +477,9 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
// Above the progress notice while a job runs.
let card = if app.progress_notice.is_some() { ctx.data(|d| d.get_temp::<f32>(egui::Id::unique(PROGRESS_HEIGHT))).unwrap_or(0.0) } else { 0.0 };
let lift = if card > 0.0 { 28.0 + card + 10.0 } else { 28.0 };
// A running batch keeps its toast up (poll_ocr re-notifies), so it can carry the one
// control a long job needs: Cancel — the file in flight finishes, the rest are not run.
let batch_running = app.ocr_batch.is_some();
egui::Area::new(egui::Id::unique("toast"))
.order(egui::Order::Tooltip)
.pivot(Align2::CENTER_BOTTOM)
@@ -494,7 +497,12 @@ pub fn toast(app: &mut PdfCraftApp, ctx: &egui::Context) {
ui.set_max_width((screen.width() - 64.0).clamp(1.0, 560.0));
let ink = if t.dark() { Color32::from_rgb(0x22, 0x22, 0x26) } else { Color32::WHITE };
let text = |ui: &mut egui::Ui| {
ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap());
ui.horizontal_wrapped(|ui| {
ui.add(egui::Label::new(egui::RichText::new(msg).color(ink)).wrap());
if batch_running && ui.small_button(crate::i18n::t("Cancel")).clicked() {
app.cancel_ocr_batch();
}
});
};
match script {
// A form script's message: its icon, then its title (when it gave one)
+1
View File
@@ -9,6 +9,7 @@ pub(super) mod export_ui;
pub(super) mod files;
pub(super) mod js_ui;
pub(super) mod ocr_ui;
pub(super) mod ocr_verify;
pub(super) mod optimize_ui;
pub(super) mod print_ui;
pub(super) mod scan_clean_ui;
+681 -81
View File
@@ -6,7 +6,7 @@ use std::sync::{Arc, Mutex};
use egui::{Align, Layout};
use pdfcraft_engine::DocId;
use pdfcraft_engine::ocr::{LANGUAGES, OcrPage, OcrSettings};
use pdfcraft_engine::ocr::{EngineChoice, LANGUAGES, MergeStrategy, OcrPage, OcrSettings, OutputMode};
use crate::theme::{self, Tokens};
use crate::{PdfCraftApp, widgets};
@@ -19,7 +19,10 @@ pub enum OcrPages {
Range,
}
/// The dialog's choices (kept between runs, as in Acrobat).
/// The dialog's choices (kept between runs, as in Acrobat). The Recognize Text dialog, the OCR
/// Verify screen and the batch options all read and write this one draft, and it is what the
/// preferences persist: language, output mode, the four preprocessing flags, engine, strategy
/// and the three skip flags — never review edits, verified flags or the selection.
#[derive(Clone, Debug, PartialEq)]
pub struct OcrDraft {
pub pages: OcrPages,
@@ -30,18 +33,135 @@ pub struct OcrDraft {
pub dpi: u32,
/// Also recognize pages that already have text (pictures on them; their text isn't added again).
pub text_pages: bool,
/// Which engine reads the pages.
pub engine: EngineChoice,
/// How two engines' readings combine.
pub strategy: MergeStrategy,
/// Preprocessing flags, all off by default.
pub auto_rotate: bool,
pub deskew: bool,
pub denoise: bool,
pub binarize: bool,
/// What Accept produces.
pub output_mode: OutputMode,
/// Batch: leave files that already contain text alone.
pub skip_text_files: bool,
/// Batch: leave pages that already contain text alone.
pub skip_text_pages: bool,
/// Batch: read everything, overriding both skips.
pub force_ocr: bool,
/// Batch: where the searchable copies go (None: ask each time). Remembered from the last
/// batch's folder pick, so a repeated export into the same place does not re-ask.
pub output_folder: Option<String>,
}
impl Default for OcrDraft {
fn default() -> Self {
OcrDraft { pages: OcrPages::All, from: 1, to: 1, language: "en".into(), dpi: 300, text_pages: false }
OcrDraft {
pages: OcrPages::All,
from: 1,
to: 1,
language: "en".into(),
dpi: 300,
text_pages: false,
engine: EngineChoice::default(),
strategy: MergeStrategy::default(),
auto_rotate: false,
deskew: false,
denoise: false,
binarize: false,
output_mode: OutputMode::default(),
skip_text_files: false,
skip_text_pages: true,
force_ocr: false,
output_folder: None,
}
}
}
impl OcrDraft {
/// The recognition settings these choices make.
pub fn settings(&self) -> OcrSettings {
OcrSettings { dpi: self.dpi as f32, language: self.language.clone(), skip_text_pages: !self.text_pages, ..Default::default() }
OcrSettings {
dpi: self.dpi as f32,
language: self.language.clone(),
engine: self.engine,
strategy: self.strategy,
auto_rotate: self.auto_rotate,
deskew: self.deskew,
denoise: self.denoise,
binarize: self.binarize,
output_mode: self.output_mode,
skip_text_files: self.skip_text_files,
skip_text_pages: self.skip_text_pages && !self.text_pages,
force_ocr: self.force_ocr,
..Default::default()
}
}
/// Restore from the preferences JSON (`ocr` object); unknown or malformed values keep the
/// defaults, like every other restored setting.
pub fn apply_pref(&mut self, v: &serde_json::Value) {
if let Some(l) = v["language"].as_str().filter(|l| LANGUAGES.iter().any(|x| x.0 == *l)) {
self.language = l.to_string();
}
if let Some(e) = v["engine"].as_str() {
self.engine = EngineChoice::parse(e);
}
if let Some(s) = v["strategy"].as_str() {
self.strategy = match s.trim().to_ascii_lowercase().as_str() {
"confidence" | "confidence-weighted" | "confidence_weighted" => MergeStrategy::ConfidenceWeighted,
"rover" | "rover-vote" | "vote" => MergeStrategy::RoverVote,
"primary" | "primary-only" => MergeStrategy::PrimaryOnly,
_ => MergeStrategy::PrimaryOnly,
};
} else if self.engine == EngineChoice::Auto {
// No strategy was saved with "Automatic": it reads the way Automatic reads by
// default, not as "Primary only" (which would never bring in the second engine).
self.strategy = self.engine.default_strategy();
}
if let Some(m) = v["output_mode"].as_str() {
self.output_mode = OutputMode::parse(m);
}
for (key, flag) in
[("auto_rotate", &mut self.auto_rotate), ("deskew", &mut self.deskew), ("denoise", &mut self.denoise), ("binarize", &mut self.binarize)]
{
if let Some(on) = v[key].as_bool() {
*flag = on;
}
}
for (key, flag) in
[("skip_text_files", &mut self.skip_text_files), ("skip_text_pages", &mut self.skip_text_pages), ("force_ocr", &mut self.force_ocr)]
{
if let Some(on) = v[key].as_bool() {
*flag = on;
}
}
if let Some(f) = v["output_folder"].as_str().filter(|f| !f.is_empty()) {
self.output_folder = Some(f.to_string());
}
}
/// The preferences JSON for `persist`.
pub fn to_pref(&self) -> serde_json::Value {
serde_json::json!({
"language": self.language,
"engine": self.engine.name(),
"strategy": match self.strategy {
MergeStrategy::PrimaryOnly => "primary",
MergeStrategy::ConfidenceWeighted => "confidence",
MergeStrategy::RoverVote => "rover",
},
"output_mode": self.output_mode.name(),
"auto_rotate": self.auto_rotate,
"deskew": self.deskew,
"denoise": self.denoise,
"binarize": self.binarize,
"skip_text_files": self.skip_text_files,
"skip_text_pages": self.skip_text_pages,
"force_ocr": self.force_ocr,
"output_folder": self.output_folder,
})
}
}
@@ -54,12 +174,14 @@ pub struct OcrProgress {
pub cancel: bool,
}
/// Recognize text in multiple files: files done, total, and the summary once finished.
/// Recognize text in multiple files: files done, total, and the summary once finished. Cancel
/// is asked for between files: the file in flight finishes, the rest are counted as not run.
#[derive(Default)]
pub struct BatchProgress {
pub done: usize,
pub total: usize,
pub message: Option<String>,
pub cancel: bool,
}
/// A page Edit should read now (see [`PdfCraftApp::scan_to_read`]).
@@ -74,10 +196,16 @@ struct ScanToRead {
pub struct OcrRun {
pub doc: DocId,
pub progress: Arc<Mutex<OcrProgress>>,
/// What the document looked like when the job started: a result is applied only while it
/// still does (the dialog closes on Go, so the user can edit, or redact, while this runs).
pub guard: pdfcraft_engine::ocr::OcrGuard,
/// Set for a run Edit started on a scanned page, not asked for: it shows no progress, says nothing
/// when it found no words (a page with text of its own is skipped, and Edit must not make noise),
/// and its result waits for Read mode and dialogs and is dropped if the document changed.
pub automatic: Option<AutoOcr>,
/// 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,
}
/// What an Edit-started run needs to know to refuse a result that no longer fits its document.
@@ -90,9 +218,16 @@ pub struct AutoOcr {
pub generation: u64,
}
/// The Tesseract probe runs in the background (the interface never waits on a process): while
/// it runs, an engine choice reads as "still checking", so look again soon.
pub(crate) fn keep_asking_while_probing(ctx: &egui::Context) {
if pdfcraft_engine::ocr::engine_probe_running() {
ctx.request_repaint_after(std::time::Duration::from_millis(200));
}
}
pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (bool, bool) {
let pages = app.active_ids().and_then(|(_, id)| app.session.get(id)).map_or(1, |d| d.info.pages.len().max(1));
let available = pdfcraft_engine::ocr::available();
let d = &mut app.ocr_draft;
d.to = d.to.clamp(1, pages);
d.from = d.from.clamp(1, d.to);
@@ -127,11 +262,55 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
}
});
ui.end_row();
ui.label(tl!("Engine"));
let engine_name = match d.engine {
EngineChoice::Auto => tl!("Automatic"),
EngineChoice::Ocrs => tl!("Built-in"),
EngineChoice::Tesseract => tl!("Tesseract"),
};
craft_ui::ComboBox::from_id_salt("ocr-engine").selected_text(engine_name).width(220.0).show_ui(ui, |ui| {
let choices: [(EngineChoice, &str); 3] =
[(EngineChoice::Auto, "Automatic"), (EngineChoice::Ocrs, "Built-in"), (EngineChoice::Tesseract, "Tesseract")];
for (choice, name) in choices {
ui.selectable_value(&mut d.engine, choice, tl!(name));
}
});
ui.end_row();
ui.label(tl!("Combine engines"));
let strategy_name = match d.strategy {
MergeStrategy::PrimaryOnly => tl!("Primary only"),
MergeStrategy::ConfidenceWeighted => tl!("Confidence weighted"),
MergeStrategy::RoverVote => tl!("ROVER vote"),
};
craft_ui::ComboBox::from_id_salt("ocr-strategy").selected_text(strategy_name).width(220.0).show_ui(ui, |ui| {
let choices: [(MergeStrategy, &str); 3] = [
(MergeStrategy::PrimaryOnly, "Primary only"),
(MergeStrategy::ConfidenceWeighted, "Confidence weighted"),
(MergeStrategy::RoverVote, "ROVER vote"),
];
for (choice, name) in choices {
ui.selectable_value(&mut d.strategy, choice, tl!(name));
}
});
ui.end_row();
ui.label(tl!("Output"));
craft_ui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| {
let _ = ui
.selectable_label(true, tl!("Searchable Image (Exact)"))
let mode_name = match d.output_mode {
OutputMode::Searchable => tl!("Searchable Image (Exact)"),
OutputMode::EditableText => tl!("Editable text"),
};
craft_ui::ComboBox::from_id_salt("ocr-output").selected_text(mode_name).width(220.0).show_ui(ui, |ui| {
let s = ui
.selectable_label(d.output_mode == OutputMode::Searchable, tl!("Searchable Image (Exact)"))
.on_hover_text(tl!("Adds invisible text over each word; the page image is not changed"));
if s.clicked() {
d.output_mode = OutputMode::Searchable;
}
let e = ui
.selectable_label(d.output_mode == OutputMode::EditableText, tl!("Editable text"))
.on_hover_text(tl!("Writes a new document of visible text; the scan is not changed"));
if e.clicked() {
d.output_mode = OutputMode::EditableText;
}
});
ui.end_row();
ui.label(tl!("Downsample to"));
@@ -145,20 +324,50 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
ui.add_space(4.0);
ui.add(crate::widgets::checkbox(&mut d.text_pages, tl!("Also recognize pages that already have text")))
.on_hover_text(tl!("Pictures on those pages are recognized too; words already in the page's text are not added again"));
ui.add(crate::widgets::checkbox(&mut d.auto_rotate, tl!("Straighten pages scanned sideways or upside down")));
ui.add(crate::widgets::checkbox(&mut d.deskew, tl!("Straighten skewed text lines")));
ui.add(crate::widgets::checkbox(&mut d.denoise, tl!("Remove scan speckle")));
ui.add(crate::widgets::checkbox(&mut d.binarize, tl!("Black and white (binarize)")));
});
if !available {
ui.label(
egui::RichText::new(tl!("Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them."))
.small()
.color(t.text_muted),
);
group(ui, "Skip", &mut |ui| {
ui.add(crate::widgets::checkbox(&mut d.skip_text_pages, tl!("Skip pages that already have text")))
.on_hover_text(tl!("Pages with renderable text are left alone and reported as skipped"));
ui.add(crate::widgets::checkbox(&mut d.skip_text_files, tl!("Skip files that already have text")))
.on_hover_text(tl!("In multiple files: a file with any text is its own skipped bucket, and nothing is written for it"));
ui.add(crate::widgets::checkbox(&mut d.force_ocr, tl!("Force OCR (override skip options)")))
.on_hover_text(tl!("Reads pages and files even where the skip options would leave them alone"));
});
group(ui, "Output folder", &mut |ui| {
ui.horizontal(|ui| match &d.output_folder {
Some(f) => {
ui.monospace(f).on_hover_text(tl!("The searchable copies are written here under the source names"));
if ui.small_button(tl!("Clear")).clicked() {
d.output_folder = None;
}
}
None => {
ui.label(tl!("Automatic")).on_hover_text(tl!("A folder is asked for when a multiple-file run starts"));
}
});
});
keep_asking_while_probing(ui.ctx());
if let Some(why) = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine) {
// 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 {
pdfcraft_engine::ocr::EngineChoice::Tesseract => crate::i18n::t(&why).to_string(),
_ => tl!("Text recognition isn't installed: its model files are missing. Reinstall PdfCraft, or set PDFCRAFT_MODELS to the folder that holds them.")
.to_string(),
};
ui.label(egui::RichText::new(text).small().color(t.text_muted));
ui.add_space(6.0);
}
ui.add_space(6.0);
let (mut go, mut cancel) = (false, false);
let can_run = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine).is_none();
ui.horizontal(|ui| {
ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
if ui.add_enabled_ui(available, |ui| widgets::pill_button(ui, tl!("Recognize text"), true)).inner.clicked() {
if ui.add_enabled_ui(can_run, |ui| widgets::pill_button(ui, tl!("Recognize text"), true)).inner.clicked() {
go = true;
}
if widgets::pill_button(ui, tl!("Cancel"), false).clicked() {
@@ -201,7 +410,9 @@ impl PdfCraftApp {
/// is one undoable step, "Recognize text", and is not run again for that page after Undo.
pub(crate) fn auto_ocr_scanned_page(&mut self) {
let Some(scan) = self.scan_to_read() else { return };
if !pdfcraft_engine::ocr::available() {
// 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);
return;
}
@@ -209,7 +420,6 @@ impl PdfCraftApp {
// Only a page with no text of its own is read. Comments and text boxes over the picture do
// not count as its text (a scan with a few comments is still a scan, as in Acrobat), but
// a page with real text and a background picture is left alone.
let mut settings = self.ocr_draft.settings();
settings.skip_text_pages = true;
settings.native_text_only = true;
self.start_ocr_pages(scan.doc, &[scan.page], settings, true);
@@ -267,7 +477,9 @@ impl PdfCraftApp {
} else {
None
};
let Some(job) = self.session.ocr_job(id, pages, settings) else { return };
let Some(job) = self.session.ocr_job(id, pages, settings.clone()) else { return };
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() }));
let p = progress.clone();
// A failure is written on the worker thread: in the UI's language.
@@ -275,8 +487,8 @@ impl PdfCraftApp {
let work = move || {
crate::i18n::set_current(lang);
let result = recognize_guarded(|| {
pdfcraft_engine::ocr::engine().map(|ocr| {
job.run(&ocr, |done, total| {
pdfcraft_engine::ocr::recognizers(&settings).map(|r| {
job.run(&r, |done, total| {
let Ok(mut s) = p.lock() else { return false };
s.done = done;
s.total = total;
@@ -296,7 +508,7 @@ impl PdfCraftApp {
}
#[cfg(target_arch = "wasm32")]
work();
self.ocr_run = Some(OcrRun { doc: id, progress, automatic: auto });
self.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: auto, editable });
self.poll_ocr();
}
@@ -307,10 +519,11 @@ impl PdfCraftApp {
self.notify_tr("Text recognition is already running");
return;
}
// The settings showing now, not when the folder arrives.
// The settings showing now, not when the folder arrives. A remembered folder (the last
// batch's pick, persisted with the preferences) is reused; otherwise the user is asked.
let settings = self.ocr_draft.settings();
#[cfg(not(target_arch = "wasm32"))]
match self.export_dir_override.clone() {
match self.export_dir_override.clone().or_else(|| self.ocr_draft.output_folder.clone()) {
Some(d) => self.ocr_files_into(files, settings, d.into()),
None => {
let dialog = rfd::AsyncFileDialog::new().set_title(tl!("Choose a folder for the searchable files").to_string());
@@ -328,21 +541,66 @@ impl PdfCraftApp {
self.notify_tr("Text recognition is already running");
return;
}
// The folder is remembered (the next batch reuses it; the dialog's Clear un-remembers).
#[cfg(not(target_arch = "wasm32"))]
{
self.ocr_draft.output_folder = Some(dir.to_string_lossy().into_owned());
}
// Two inputs of one file name (from different folders) would write the same output
// (or download under the same name): refused before anything is read.
let twice = pdfcraft_engine::ocr::duplicate_output_names(files.iter().map(|(name, _)| name.as_str()));
if !twice.is_empty() {
self.notify_error(crate::i18n::fmt(
tl!("Several files have the same name, so their results would overwrite each other; nothing was written: {list}"),
&[("list", &twice.join(", "))],
));
return;
}
// Collision pre-flight: a searchable copy never silently overwrites. Every target is
// checked before the first page is read; a collision stops the batch here.
#[cfg(not(target_arch = "wasm32"))]
{
let hits: Vec<String> = files
.iter()
.filter_map(|(name, _)| {
let target = dir.join(name);
target.exists().then(|| target.to_string_lossy().into_owned())
})
.take(5)
.collect();
if !hits.is_empty() {
self.notify_error(crate::i18n::fmt(
tl!("The output folder already holds files of these names; nothing was written: {list}"),
&[("list", &hits.join(", "))],
));
return;
}
}
// The engines are built (and named if they cannot be) before anything starts.
let recognizers = match pdfcraft_engine::ocr::recognizers(&settings) {
Ok(r) => r,
Err(why) => {
self.notify_error(why);
return;
}
};
let progress = Arc::new(Mutex::new(BatchProgress { total: files.len(), ..Default::default() }));
let p = progress.clone();
// The summary is written on the worker thread: draw it in the UI's language.
let lang = crate::i18n::current();
let work = move || {
crate::i18n::set_current(lang);
// A panic in the recognizer still ends the batch with a summary (#3919).
#[cfg(not(target_arch = "wasm32"))]
let summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p, &dir));
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 summary = pdfcraft_engine::guard(|| recognize_files(files, &settings, &p));
let summary = summary.unwrap_or_else(|panic| failed(&panic));
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() {
s.done = s.total;
s.message = Some(summary);
s.message = Some(msg);
}
};
#[cfg(not(target_arch = "wasm32"))]
@@ -356,54 +614,17 @@ impl PdfCraftApp {
self.ocr_batch = Some(progress);
self.poll_ocr();
}
}
/// Recognize each file and write its searchable copy into `dir`; returns the summary to show.
fn recognize_files(
files: Vec<(String, Vec<u8>)>,
settings: &OcrSettings,
p: &Mutex<BatchProgress>,
#[cfg(not(target_arch = "wasm32"))] dir: &std::path::Path,
) -> String {
let (mut ok, mut words, mut failed) = (0, 0, Vec::new());
match pdfcraft_engine::ocr::engine() {
Err(e) => failed.push(e),
Ok(ocr) => {
for (i, (name, bytes)) in files.into_iter().enumerate() {
if let Ok(mut s) = p.lock() {
s.done = i;
}
let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), &ocr, |_, _| true);
let saved = r.and_then(|r| {
#[cfg(not(target_arch = "wasm32"))]
crate::editing::write_atomically(&dir.join(&name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())?;
#[cfg(target_arch = "wasm32")]
crate::editing::download(&name, &r.bytes)?;
Ok(r.words())
});
match saved {
Ok(n) => {
ok += 1;
words += n;
}
Err(e) => failed.push(format!("{name}: {}", e)),
}
}
/// Ask the running batch to stop: the file in flight finishes, the rest are counted as not
/// run.
pub fn cancel_ocr_batch(&mut self) {
if let Some(b) = &self.ocr_batch
&& let Ok(mut s) = b.lock()
{
s.cancel = true;
}
}
let done = if ok == 1 {
crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())])
} else {
crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())])
};
let mut msg = done;
if !failed.is_empty() {
msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))]));
}
msg
}
impl PdfCraftApp {
/// Stop a running recognition (the pages read so far are kept).
pub fn cancel_ocr(&mut self) {
if let Some(r) = &self.ocr_run
@@ -442,7 +663,7 @@ impl PdfCraftApp {
}
}
let Some(run) = self.ocr_run.as_ref() else { return };
let (doc, progress, auto) = (run.doc, run.progress.clone(), run.automatic);
let (doc, progress, auto, guard, editable) = (run.doc, run.progress.clone(), run.automatic, run.guard.clone(), run.editable);
let automatic = auto.is_some();
let mut s = match progress.lock() {
Ok(s) => s,
@@ -498,9 +719,20 @@ impl PdfCraftApp {
return;
}
};
// The dialog's "Editable text" choice is honored: the recognition is not written as an
// invisible layer here. It opens in the OCR Verify screen, whose Accept produces the
// editable document (a NEW file) the choice promises.
if editable && !automatic {
self.ocr_verify.load(doc, guard, found);
self.ocr_verify.open = true;
self.notify_tr("Review the recognition, then Accept to write the editable document");
return;
}
let read = found.iter().filter(|p| p.skipped.is_none()).count();
let skipped = found.len() - read;
match self.session.apply_ocr(doc, &found) {
// The guard refuses a result that no longer fits its document (the user edited, or
// redacted, while the worker was out); its reason says so.
match self.session.apply_ocr_checked(doc, &found, &guard) {
Ok(words) => {
if let Some(info) = self.session.get(doc).map(|d| d.info.clone())
&& let Some(view) = self.views.iter_mut().find(|v| v.id == doc)
@@ -525,6 +757,12 @@ impl PdfCraftApp {
crate::i18n::fmt(tl!("; {n} pages already had text"), &[("n", &skipped.to_string())])
});
}
// The pages' own notes (the editable output's unrenderable-character warning,
// for one) are part of the result: said here, not dropped.
for note in found.iter().flat_map(|p| p.notes.iter()) {
msg.push_str("; ");
msg.push_str(note);
}
self.notify(msg);
}
Err(e) => self.notify_error(e),
@@ -532,6 +770,77 @@ impl PdfCraftApp {
}
}
/// The batch's worker: read each file, hand the searchable copy to `write` (the output folder,
/// 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.
pub(crate) fn run_batch_files(
files: Vec<(String, Vec<u8>)>,
settings: OcrSettings,
recognizers: &pdfcraft_engine::ocr::Recognizers,
progress: &Mutex<BatchProgress>,
mut write: impl FnMut(&str, &pdfcraft_engine::ocr::FileResult) -> Result<(), String>,
) -> String {
let total = files.len();
let (mut ok, mut skipped, mut words, mut failed, mut notes) = (0usize, 0usize, 0usize, Vec::new(), Vec::new());
let mut not_run: Option<usize> = None;
for (i, (name, bytes)) in files.into_iter().enumerate() {
if progress.lock().map(|s| s.cancel).unwrap_or(false) {
not_run = Some(total - i);
break;
}
if let Ok(mut s) = progress.lock() {
s.done = i;
}
let r = pdfcraft_engine::ocr::recognize_file(&name, Arc::new(bytes), None, settings.clone(), recognizers, |_, _| true);
match r {
Ok(res) if res.skipped() => skipped += 1,
Ok(res) => match write(&name, &res) {
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)],
));
}
}
Err(e) => failed.push(format!("{name}: {e}")),
},
Err(e) => failed.push(format!("{name}: {e}")),
}
}
batch_summary(ok, words, skipped, &failed, &notes, not_run)
}
/// 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 {
let mut msg = if ok == 1 {
crate::i18n::fmt(tl!("Recognized {w} words in 1 file"), &[("w", &words.to_string())])
} else {
crate::i18n::fmt(tl!("Recognized {w} words in {n} files"), &[("w", &words.to_string()), ("n", &ok.to_string())])
};
if skipped > 0 {
msg.push_str(&crate::i18n::fmt(tl!("; {n} files already had text"), &[("n", &skipped.to_string())]));
}
if !failed.is_empty() {
msg.push_str(&crate::i18n::fmt(tl!("; failed: {list}"), &[("list", &failed.join("; "))]));
}
if let Some(rest) = not_run {
msg.push_str(&crate::i18n::fmt(tl!("; cancelled — {n} files were not run"), &[("n", &rest.to_string())]));
}
for note in notes {
msg.push_str("; ");
msg.push_str(note);
}
msg
}
/// "Recognize text failed: {e}", in the current language.
fn failed(e: &str) -> String {
crate::i18n::fmt(tl!("{label} failed: {e}"), &[("label", tl!("Recognize text")), ("e", e)])
@@ -547,6 +856,7 @@ fn recognize_guarded<T>(recognize: impl FnOnce() -> Result<T, String>) -> Result
mod tests {
use super::*;
use crate::{Dialog, LeftPanel, Mode, QuickTool};
use pdfcraft_engine::ocr::{Recognizer, Recognizers};
use pdfcraft_engine::{Edit, Session};
/// A one-page PDF that is one picture covering the page.
@@ -629,12 +939,26 @@ mod tests {
/// asked for is applied at once. The result is made up, so no OCR models are needed.
#[test]
fn the_result_of_an_automatic_run_waits_for_read_mode_and_dialogs() {
let word = || pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] };
let word = || pdfcraft_engine::ocr::PlacedWord {
text: "scanned".into(),
origin: [72.0, 700.0],
across: [60.0, 0.0],
up: [0.0, 12.0],
confidence: None,
source: "ocrs".into(),
};
let finished = |app: &PdfCraftApp, automatic: bool| {
let generation = app.session.get(app.active_ids().unwrap().1).unwrap().edit_generation();
let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None };
let page = pdfcraft_engine::ocr::OcrPage { page: 0, words: vec![word()], already_on_page: 0, skipped: None, notes: Vec::new() };
let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false }));
OcrRun { doc: app.active_ids().unwrap().1, progress, automatic: automatic.then_some(AutoOcr { page: 0, generation }) }
let guard = app.session.ocr_guard(app.active_ids().unwrap().1).unwrap();
OcrRun {
doc: app.active_ids().unwrap().1,
progress,
guard,
automatic: automatic.then_some(AutoOcr { page: 0, generation }),
editable: false,
}
};
let label = |app: &PdfCraftApp| app.session.get(app.active_ids().unwrap().1).unwrap().can_undo().map(String::from);
@@ -669,12 +993,21 @@ mod tests {
let generation = app.session.get(id).unwrap().edit_generation();
let page = pdfcraft_engine::ocr::OcrPage {
page: 0,
words: vec![pdfcraft_engine::ocr::PlacedWord { text: "scanned".into(), origin: [72.0, 700.0], across: [60.0, 0.0], up: [0.0, 12.0] }],
words: vec![pdfcraft_engine::ocr::PlacedWord {
text: "scanned".into(),
origin: [72.0, 700.0],
across: [60.0, 0.0],
up: [0.0, 12.0],
confidence: None,
source: "ocrs".into(),
}],
already_on_page: 0,
skipped: None,
notes: Vec::new(),
};
let progress = Arc::new(Mutex::new(OcrProgress { done: 1, total: 1, result: Some(Ok(vec![page])), cancel: false }));
app.ocr_run = Some(OcrRun { doc: id, progress, automatic: Some(AutoOcr { page: 0, generation }) });
let guard = app.session.ocr_guard(id).unwrap();
app.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: Some(AutoOcr { page: 0, generation }), editable: false });
app.views[0].auto_ocr_tried.insert(0);
// The document changes while the run is out.
let mut png = std::io::Cursor::new(Vec::new());
@@ -712,9 +1045,13 @@ mod tests {
fn a_run_whose_worker_died_ends_instead_of_waiting_forever() {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
let pdf = pdfcraft_engine::Session::new().create_from_text("t", "guard fixture").unwrap();
app.open_bytes("a.pdf", None, pdf.to_vec()).unwrap();
let id = app.active_ids().unwrap().1;
let guard = app.session.ocr_guard(id).unwrap();
let progress = Arc::new(Mutex::new(OcrProgress { total: 3, ..Default::default() }));
poison(&progress);
app.ocr_run = Some(OcrRun { doc: DocId(1), progress, automatic: None });
app.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: None, editable: false });
app.poll_ocr();
assert!(app.ocr_run.is_none(), "the next run isn't refused as already running");
let toast = app.toast.take().map(|t| t.0).unwrap_or_default();
@@ -728,4 +1065,267 @@ mod tests {
let toast = app.toast.take().map(|t| t.0).unwrap_or_default();
assert!(toast.starts_with("Recognize text failed"), "{toast}");
}
/// A fake engine: one confident word per call, no filesystem, no models needed.
struct Fake {
confidence: f32,
}
impl Recognizer for Fake {
fn id(&self) -> &'static str {
"fake"
}
fn available(&self) -> bool {
true
}
fn languages(&self) -> Vec<(String, String)> {
vec![("en".into(), "English".into())]
}
fn recognize(
&self,
_image: &pdfcraft_ocr::OcrImage,
_options: &pdfcraft_ocr::RecognizeOptions,
) -> Result<Vec<pdfcraft_ocr::Line>, pdfcraft_engine::ocr::OcrError> {
Ok(vec![pdfcraft_ocr::Line {
words: vec![pdfcraft_ocr::Word::new("word", [1.0, 1.0, 9.0, 9.0], "fake").with_confidence(self.confidence)],
}])
}
}
/// A batch over two text files (the engine never runs on them: they are skipped files):
/// the summary counts them in their own bucket, nothing is written, and a cancel before
/// the first file leaves the rest not run.
#[test]
fn batch_counts_skipped_files_and_honours_a_cancel() {
let s = pdfcraft_engine::Session::new();
let pdf = s.create_from_text("t", "Already typed here").expect("fixture");
let files = vec![("a.pdf".into(), pdf.to_vec()), ("b.pdf".into(), pdf.to_vec())];
let progress = Mutex::new(BatchProgress { total: files.len(), cancel: true, ..Default::default() });
let recognizers = Recognizers { primary: Arc::new(Fake { confidence: 95.0 }), secondary: None };
let writes = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let w = writes.clone();
let msg = run_batch_files(files, OcrSettings { skip_text_files: true, ..Default::default() }, &recognizers, &progress, move |_, _| {
w.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
assert_eq!(writes.load(std::sync::atomic::Ordering::SeqCst), 0, "nothing is written for skipped files, and the cancel came first");
assert!(msg.contains("cancelled — 2 files were not run"), "{msg}");
// Without the cancel, the skipped bucket is named and still nothing is written.
let files = vec![("a.pdf".into(), pdf.to_vec()), ("b.pdf".into(), pdf.to_vec())];
let progress = Mutex::new(BatchProgress { total: files.len(), ..Default::default() });
let w = writes.clone();
let msg = run_batch_files(files, OcrSettings { skip_text_files: true, ..Default::default() }, &recognizers, &progress, move |_, _| {
w.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(())
});
assert_eq!(writes.load(std::sync::atomic::Ordering::SeqCst), 0);
assert!(msg.contains("; 2 files already had text"), "{msg}");
assert!(msg.contains("Recognized 0 words in 0 files"), "{msg}");
}
/// A batch over scanned files: the words are counted, and a low-confidence reading lands
/// in the summary as a note with its 1-based sorted pages.
#[test]
fn batch_notes_low_confidence_pages() {
let s = pdfcraft_engine::Session::new();
let pdf = s.create_from_text("t", "fake scan").expect("fixture");
let files = vec![("scan.pdf".into(), pdf.to_vec()), ("clean.pdf".into(), pdf.to_vec())];
let progress = Mutex::new(BatchProgress { total: files.len(), ..Default::default() });
let recognizers = Recognizers { primary: Arc::new(Fake { confidence: 40.0 }), secondary: None };
// The fixtures are text PDFs standing in for scans; the default skip-text-pages option
// would (correctly) leave them alone, so the batch here reads everything.
let settings = OcrSettings { skip_text_pages: false, ..Default::default() };
let msg = run_batch_files(files, settings.clone(), &recognizers, &progress, |_, _| Ok(()));
assert!(msg.contains("Recognized 2 words in 2 files"), "{msg}");
assert!(msg.contains("scan.pdf: 1 low-confidence word(s) on pages 1"), "{msg}");
// The write failing is a per-file failure, named in the summary.
let files = vec![("scan.pdf".into(), pdf.to_vec())];
let progress = Mutex::new(BatchProgress { total: files.len(), ..Default::default() });
let msg = run_batch_files(files, settings, &recognizers, &progress, |_, _| Err("disk full".into()));
assert!(msg.contains("; failed: scan.pdf: disk full"), "{msg}");
}
/// The batch note follows the settings' low-confidence threshold (60 by default), not a
/// number of its own: a 40% word is flagged under 60 and under 40, and not under 30.
#[test]
fn the_batch_note_uses_the_settings_threshold() {
let s = pdfcraft_engine::Session::new();
let pdf = s.create_from_text("t", "fake scan").expect("fixture");
let recognizers = Recognizers { primary: Arc::new(Fake { confidence: 40.0 }), secondary: None };
let run = |threshold: f32| {
let files = vec![("scan.pdf".into(), pdf.to_vec())];
let progress = Mutex::new(BatchProgress { total: files.len(), ..Default::default() });
let settings = OcrSettings { skip_text_pages: false, low_confidence_threshold: threshold, ..Default::default() };
run_batch_files(files, settings, &recognizers, &progress, |_, _| Ok(()))
};
assert!(run(OcrSettings::default().low_confidence_threshold).contains("1 low-confidence word(s)"), "the default flags a 40% word");
assert!(run(40.0).contains("1 low-confidence word(s)"), "at the threshold counts");
assert!(!run(30.0).contains("low-confidence"), "a lower threshold leaves the word alone: {}", run(30.0));
}
/// The remembered output folder survives the preferences round trip; an absent or empty
/// value keeps the default (ask each time).
#[test]
fn the_output_folder_is_persisted_with_the_draft() {
let mut d = OcrDraft::default();
assert_eq!(d.output_folder, None, "by default the folder is asked for");
d.output_folder = Some("out".into());
let mut r = OcrDraft::default();
r.apply_pref(&d.to_pref());
assert_eq!(r.output_folder.as_deref(), Some("out"));
let mut e = OcrDraft::default();
e.apply_pref(&serde_json::json!({}));
assert_eq!(e.output_folder, None, "an absent value keeps the default");
e.output_folder = Some("kept".into());
e.apply_pref(&serde_json::json!({ "output_folder": "" }));
assert_eq!(e.output_folder.as_deref(), Some("kept"), "an empty value is not a folder");
}
/// "Automatic" saved without a strategy reads the way Automatic reads by default (the
/// second engine joins when the first is unsure); an explicit choice always stands.
#[test]
fn an_automatic_engine_without_a_saved_strategy_is_confidence_weighted() {
let mut d = OcrDraft::default();
d.apply_pref(&serde_json::json!({ "engine": "auto" }));
assert_eq!((d.engine, d.strategy), (EngineChoice::Auto, MergeStrategy::ConfidenceWeighted));
let mut named = OcrDraft::default();
named.apply_pref(&serde_json::json!({ "engine": "tesseract" }));
assert_eq!(named.strategy, MergeStrategy::PrimaryOnly, "a named engine reads alone");
let mut explicit = OcrDraft::default();
explicit.apply_pref(&serde_json::json!({ "engine": "auto", "strategy": "primary" }));
assert_eq!((explicit.engine, explicit.strategy), (EngineChoice::Auto, MergeStrategy::PrimaryOnly));
}
/// Two inputs of one file name (from different folders, or differing only in case) would
/// write the same output: the batch stops before anything is read or written.
#[test]
fn inputs_that_share_a_file_name_are_refused_before_anything_is_read() {
let mut app = PdfCraftApp::new();
app.run_inline = true;
let dir = std::env::temp_dir().join(format!("pdfcraft-ocr-same-name-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("fixture");
app.ocr_draft.output_folder = Some(dir.to_string_lossy().into_owned());
let s = pdfcraft_engine::Session::new();
let pdf = s.create_from_text("t", "scanned words here").expect("fixture");
app.ocr_files(vec![("scan.pdf".into(), pdf.to_vec()), ("other.pdf".into(), pdf.to_vec()), ("Scan.PDF".into(), pdf.to_vec())]);
assert!(app.ocr_batch.is_none(), "the batch never starts");
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("same name") && toast.contains("Scan.PDF"), "{toast}");
assert_eq!(std::fs::read_dir(&dir).expect("fixture").count(), 0, "nothing was written");
let _ = std::fs::remove_dir_all(&dir);
}
/// A remembered output folder is used without asking, is remembered again after the run,
/// and its collision pre-flight still stops the batch before the first page is read.
#[test]
fn a_remembered_output_folder_is_used_and_still_preflights_collisions() {
let mut app = PdfCraftApp::new();
app.run_inline = true;
let dir = std::env::temp_dir().join(format!("pdfcraft-ocr-folder-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("fixture");
std::fs::write(dir.join("a.pdf"), b"would be overwritten").expect("fixture");
app.ocr_draft.output_folder = Some(dir.to_string_lossy().into_owned());
let s = pdfcraft_engine::Session::new();
let pdf = s.create_from_text("t", "scanned words here").expect("fixture");
app.ocr_files(vec![("a.pdf".into(), pdf.to_vec()), ("b.pdf".into(), pdf.to_vec())]);
assert!(app.ocr_batch.is_none(), "the collision stops the batch before anything is read");
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("already holds files of these names"), "{toast}");
assert!(toast.contains("a.pdf"), "the colliding names are in the error: {toast}");
assert_eq!(app.ocr_draft.output_folder.as_deref(), Some(dir.to_string_lossy().as_ref()), "the folder stays remembered for the next batch");
let _ = std::fs::remove_dir_all(&dir);
}
/// A result that arrives after the document changed (here: a page turned while the worker was
/// reading; the same applies to a redaction applied meanwhile) is refused with the reason and
/// writes nothing, so words read before a redaction cannot come back as invisible text.
#[test]
fn a_result_that_arrives_after_the_document_changed_is_refused() {
let mut app = PdfCraftApp::new();
let pdf = pdfcraft_engine::Session::new().create_from_text("t", "Typed text").expect("fixture");
app.open_bytes("a.pdf", None, pdf.to_vec()).expect("opens");
let id = app.active_ids().expect("a document").1;
let guard = app.session.ocr_guard(id).expect("a guard");
app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("an edit while the worker runs");
let after_edit = app.session.get(id).and_then(|d| d.can_undo().map(str::to_owned));
let word = pdfcraft_engine::ocr::PlacedWord {
text: "123-45-6789".into(),
origin: [40.0, 40.0],
across: [50.0, 0.0],
up: [0.0, 12.0],
confidence: Some(95.0),
source: "fake".into(),
};
let page = OcrPage { page: 0, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() };
let progress = Arc::new(Mutex::new(OcrProgress { total: 1, done: 1, result: Some(Ok(vec![page])), cancel: false }));
app.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: None, editable: false });
app.poll_ocr();
assert!(app.ocr_run.is_none(), "the run is over");
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("changed since recognition started"), "{toast}");
assert_eq!(
app.session.get(id).and_then(|d| d.can_undo().map(str::to_owned)),
after_edit,
"nothing was written: the last undoable step is still the edit"
);
}
/// A page's notes survive to the result the user sees: the editable output's
/// unrenderable-character warning ("N characters had no font … appear as ?") is said in
/// the run's summary, not dropped. The result is fabricated, so no models are needed.
#[test]
fn the_result_summary_names_the_substituted_characters() {
let mut app = app_with(scan());
let id = app.active_ids().unwrap().1;
let word = pdfcraft_engine::ocr::PlacedWord {
text: "scanned".into(),
origin: [72.0, 700.0],
across: [60.0, 0.0],
up: [0.0, 12.0],
confidence: Some(95.0),
source: "ocrs".into(),
};
let page = pdfcraft_engine::ocr::OcrPage {
page: 0,
words: vec![word],
already_on_page: 0,
skipped: None,
notes: vec!["3 characters had no font that could show them and appear as ? in the output".into()],
};
let guard = app.session.ocr_guard(id).unwrap();
let progress = Arc::new(Mutex::new(OcrProgress { total: 1, done: 1, result: Some(Ok(vec![page])), cancel: false }));
app.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: None, editable: false });
app.poll_ocr();
let toast = app.toast.take().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("3 characters had no font"), "{toast}");
}
/// The dialog's "Editable text" output is honored: a run that promised an editable document
/// does not quietly write the invisible searchable layer — its result opens in the OCR
/// Verify screen, whose Accept produces the editable document. The result is fabricated,
/// so no models are needed.
#[test]
fn an_editable_output_run_opens_the_review_instead_of_applying() {
let mut app = app_with(scan());
app.run_inline = true;
let id = app.active_ids().unwrap().1;
let word = pdfcraft_engine::ocr::PlacedWord {
text: "scanned".into(),
origin: [72.0, 700.0],
across: [60.0, 0.0],
up: [0.0, 12.0],
confidence: Some(95.0),
source: "ocrs".into(),
};
let page = OcrPage { page: 0, words: vec![word], already_on_page: 0, skipped: None, notes: Vec::new() };
let guard = app.session.ocr_guard(id).unwrap();
let progress = Arc::new(Mutex::new(OcrProgress { total: 1, done: 1, result: Some(Ok(vec![page])), cancel: false }));
app.ocr_draft.output_mode = pdfcraft_engine::ocr::OutputMode::EditableText;
app.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: None, editable: true });
app.poll_ocr();
assert!(app.ocr_run.is_none(), "the run is over");
assert_eq!(app.session.get(id).unwrap().can_undo(), None, "no invisible layer was written");
assert!(app.ocr_verify.open, "the review opens, to accept as an editable document");
assert_eq!(app.ocr_verify.phase, crate::workflows::ocr_verify::Phase::ReviewReady);
}
}
File diff suppressed because it is too large. Load diff
+118 -1
View File
@@ -3,6 +3,7 @@
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::ocr::{OcrPage, OcrSettings, PlacedWord};
use pdfcraft_engine::{AddedText, Edit, Session, export};
use pdfcraft_ui_egui::PdfCraftApp;
@@ -127,7 +128,14 @@ fn an_ocr_word_opens_for_editing_and_stays_open() {
app.open_bytes("scan.pdf", None, scan()).unwrap();
let id = app.views[0].id;
let ph = app.session.get(id).unwrap().info.pages[0].height as f64;
let words = vec![PlacedWord { text: "Recognize".into(), origin: [72.0, ph - 100.0], across: [80.0, 0.0], up: [0.0, 14.0] }];
let words = vec![PlacedWord {
text: "Recognize".into(),
origin: [72.0, ph - 100.0],
across: [80.0, 0.0],
up: [0.0, 14.0],
confidence: None,
source: "ocrs".into(),
}];
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
app.set_option("zoom", "100").unwrap();
assert!(app.execute("edit.edit_text"));
@@ -348,3 +356,112 @@ fn only_the_edit_tool_recognizes_a_scanned_page() {
h.run_steps(6);
assert_eq!(h.state().session.get(id).unwrap().can_undo(), None);
}
/// A placed word the review can hold (its box sits where a real one would).
fn placed(text: &str, confidence: f32) -> PlacedWord {
PlacedWord { text: text.into(), origin: [40.0, 40.0], across: [50.0, 0.0], up: [0.0, 12.0], confidence: Some(confidence), source: "ocrs".into() }
}
/// The OCR Verify screen (Scan & OCR ▸ Correct recognized text) in the real shell: every
/// control is live, the keyboard walks the uncertain words, the inspector removes and restores
/// through clicks, and Accept applies the review as one undoable step. The recognition is
/// loaded directly, so this runs without the OCR models.
#[test]
fn ocr_verify_screen_controls_keyboard_and_accept() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
// The screen owns the window and its controls are all present (no dead controls).
h.get_by_label("Run OCR");
h.get_by_label("Reject");
h.get_by_label("Next uncertain");
h.get_by_label("Previous uncertain");
h.get_by_label("Page verified");
h.get_by_label("Exit");
use pdfcraft_ui_egui::Phase;
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// The guards hold in the real UI: Reject outside ReviewReady is ignored, not acted on.
h.get_by_label("Reject").click();
h.run_steps(2);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// A recognition result, as the worker would deliver it.
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page =
OcrPage { already_on_page: 0, page: 0, words: vec![placed("Helo", 40.0), placed("wrold", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::ReviewReady);
// F8 walks to the Low-band word; the inspector shows it.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, Some((0, 0)));
h.get_by_label("Remove").click();
h.run_steps(2);
assert!(h.state().ocr_verify.is_removed((0, 0)), "Remove strikes the word out");
h.get_by_label("Restore").click();
h.run_steps(2);
assert!(!h.state().ocr_verify.is_removed((0, 0)), "Restore brings it back");
// Ctrl+T marks the page reviewed (the checkbox flips).
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(h.state().ocr_verify.review.verified_pages.contains(&0), "Ctrl+T marks the page verified");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(!h.state().ocr_verify.review.verified_pages.contains(&0));
// Accept applies the whole review as ONE undoable step on the open document.
h.get_by_label("Accept").click();
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle, "a successful accept ends the review");
let app = h.state();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text"));
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("one undoable step"), "{toast}");
}
/// Ctrl+Tab moves the pane focus ring, and F8 with nothing to review is a no-op: the walk
/// never invents a selection. The recognition is loaded directly (no models needed).
#[test]
fn ocr_verify_screen_focus_ring_and_empty_walk() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page = OcrPage { already_on_page: 0, page: 0, words: vec![placed("Fine", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
// Nothing asks for review: F8 selects nothing instead of wrapping into an error.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, None);
// Ctrl+Tab cycles the four panes; four steps come back to the start.
let start = h.state().ocr_verify.focus;
let mut moved = false;
for _ in 0..4 {
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Tab);
h.run_steps(2);
moved |= h.state().ocr_verify.focus != start;
}
assert!(moved, "Ctrl+Tab moves the focus");
assert_eq!(h.state().ocr_verify.focus, start, "the ring returns after the four panes");
}
+281
View File
@@ -0,0 +1,281 @@
//! Scan & OCR ▸ Recognize text in the real shell (egui_kittest): the dialog, the run and its
//! result (skipped without the OCR models: `cargo xtask models`).
use egui_kittest::Harness;
use egui_kittest::kittest::Queryable;
use pdfcraft_engine::ocr::{OcrPage, OcrSettings, PlacedWord};
use pdfcraft_engine::{AddedText, Edit, Session, export};
use pdfcraft_ui_egui::PdfCraftApp;
/// A one-page PDF that is only a picture of a sentence.
fn scan() -> Vec<u8> {
let mut s = Session::new();
let text = s.create_from_text("t", "Recognize these scanned words").unwrap();
let id = s.open("text.pdf", None, text, None).unwrap();
let png = export::Exporter::new(s.get(id).unwrap()).png(0, 150.0).unwrap();
s.create_from_images(&[("scan.png".into(), png)]).unwrap().to_vec()
}
#[test]
fn recognize_text_dialog_adds_searchable_text() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.recognize"));
h.run_steps(2);
h.get_by_label("Document language");
h.get_by_label("Output");
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
h.get_by_label("Recognize text").click();
h.run_steps(3);
assert!(h.state().ocr_run.is_none(), "finished");
let toast = h.state().toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.contains("on 1 page"), "{toast}");
let app = h.state();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text"));
}
#[test]
fn recognize_text_in_multiple_files_writes_searchable_copies() {
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
let dir = std::env::temp_dir().join(format!("pdfcraft-ocr-ui-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.run_inline = true;
app.export_dir_override = Some(dir.to_string_lossy().into_owned());
app.use_files(pdfcraft_ui_egui::FilePurpose::Ocr, vec![("one.pdf".into(), scan()), ("two.pdf".into(), scan())]);
assert!(app.ocr_batch.is_none(), "finished");
let toast = app.toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.ends_with("in 2 files"), "{toast}");
for name in ["one.pdf", "two.pdf"] {
let bytes = std::fs::read(dir.join(name)).unwrap();
assert!(bytes.len() > scan().len(), "an incremental update was appended");
}
}
/// A placed word the review can hold (its box sits where a real one would).
fn placed(text: &str, confidence: f32) -> PlacedWord {
PlacedWord { text: text.into(), origin: [40.0, 40.0], across: [50.0, 0.0], up: [0.0, 12.0], confidence: Some(confidence), source: "ocrs".into() }
}
/// The OCR Verify screen (Scan & OCR ▸ Correct recognized text) in the real shell: every
/// control is live, the keyboard walks the uncertain words, the inspector removes and restores
/// through clicks, and Accept applies the review as one undoable step. The recognition is
/// loaded directly, so this runs without the OCR models.
#[test]
fn ocr_verify_screen_controls_keyboard_and_accept() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
// The screen owns the window and its controls are all present (no dead controls).
h.get_by_label("Run OCR");
h.get_by_label("Reject");
h.get_by_label("Next uncertain");
h.get_by_label("Previous uncertain");
h.get_by_label("Page verified");
h.get_by_label("Exit");
use pdfcraft_ui_egui::Phase;
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// The guards hold in the real UI: Reject outside ReviewReady is ignored, not acted on.
h.get_by_label("Reject").click();
h.run_steps(2);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle);
// A recognition result, as the worker would deliver it.
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page =
OcrPage { already_on_page: 0, page: 0, words: vec![placed("Helo", 40.0), placed("wrold", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::ReviewReady);
// F8 walks to the Low-band word; the inspector shows it.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, Some((0, 0)));
h.get_by_label("Remove").click();
h.run_steps(2);
assert!(h.state().ocr_verify.is_removed((0, 0)), "Remove strikes the word out");
h.get_by_label("Restore").click();
h.run_steps(2);
assert!(!h.state().ocr_verify.is_removed((0, 0)), "Restore brings it back");
// Ctrl+T marks the page reviewed (the checkbox flips).
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(h.state().ocr_verify.review.verified_pages.contains(&0), "Ctrl+T marks the page verified");
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::T);
h.run_steps(2);
assert!(!h.state().ocr_verify.review.verified_pages.contains(&0));
// Accept applies the whole review as ONE undoable step on the open document.
h.get_by_label("Accept").click();
h.run_steps(3);
assert_eq!(h.state().ocr_verify.phase, Phase::Idle, "a successful accept ends the review");
let app = h.state();
let id = app.active_ids().unwrap().1;
assert_eq!(app.session.get(id).unwrap().can_undo(), Some("Recognize text"));
let toast = app.toast.clone().map(|t| t.0).unwrap_or_default();
assert!(toast.contains("one undoable step"), "{toast}");
}
/// Ctrl+Tab moves the pane focus ring, and F8 with nothing to review is a no-op: the walk
/// never invents a selection. The recognition is loaded directly (no models needed).
#[test]
fn ocr_verify_screen_focus_ring_and_empty_walk() {
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.open_bytes("scan.pdf", None, scan()).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.correct"));
h.run_steps(3);
{
let app = h.state_mut();
let id = app.active_ids().unwrap().1;
let job = app.session.ocr_job(id, &[0], OcrSettings::default()).unwrap();
let page = OcrPage { already_on_page: 0, page: 0, words: vec![placed("Fine", 95.0)], skipped: None, notes: Vec::new() };
app.ocr_verify.load(id, job.guard.clone(), vec![page]);
}
h.run_steps(3);
// Nothing asks for review: F8 selects nothing instead of wrapping into an error.
h.key_press(egui::Key::F8);
h.run_steps(2);
assert_eq!(h.state().ocr_verify.selected, None);
// Ctrl+Tab cycles the four panes; four steps come back to the start.
let start = h.state().ocr_verify.focus;
let mut moved = false;
for _ in 0..4 {
h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::Tab);
h.run_steps(2);
moved |= h.state().ocr_verify.focus != start;
}
assert!(moved, "Ctrl+Tab moves the focus");
assert_eq!(h.state().ocr_verify.focus, start, "the ring returns after the four panes");
}
/// #1028: "Also recognize pages that already have text" reads a picture on a page that has its own
/// text, without adding that text a second time.
#[test]
fn recognize_text_can_read_pictures_on_pages_with_text() {
let hybrid = {
let mut s = Session::new();
let id = s.open("scan.pdf", None, std::sync::Arc::new(scan()), None).unwrap();
let (w, h) = {
let p = &s.get(id).unwrap().info.pages[0];
(p.width as f64, p.height as f64)
};
let note =
AddedText { rect: [w * 0.1, h * 0.75, w * 0.9, h * 0.85], text: "Invoice reference 4711".into(), size: 18.0, ..Default::default() };
s.apply(id, Edit::AddText { page: 0, text: note }).unwrap();
s.save_bytes(id).unwrap().to_vec()
};
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("hybrid.pdf", None, hybrid).unwrap();
app.run_inline = true;
app
});
h.run_steps(4);
assert!(h.state_mut().execute("ocr.recognize"));
h.run_steps(2);
assert!(h.state().ocr_draft.settings().skip_text_pages, "pages with text are skipped by default");
h.get_by_label("Also recognize pages that already have text").click();
h.run_steps(2);
assert!(!h.state().ocr_draft.settings().skip_text_pages);
if !pdfcraft_engine::ocr::available() {
eprintln!("skipped: OCR models not installed");
return;
}
h.get_by_label("Recognize text").click();
h.run_steps(3);
assert!(h.state().ocr_run.is_none(), "finished");
let toast = h.state().toast.clone().unwrap().0;
assert!(toast.starts_with("Recognized ") && toast.ends_with("on 1 page"), "{toast}");
let app = h.state();
let id = app.active_ids().unwrap().1;
let doc = app.session.get(id).unwrap();
assert_eq!(doc.can_undo(), Some("Recognize text"));
let mut r = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), Default::default());
let text = r.render(pdfcraft_render::RenderRequest { page: 0, kind: pdfcraft_render::RequestKind::Text, scale: 1.0, ..Default::default() });
let text = text.text.unwrap().plain_text().to_lowercase();
assert_eq!((text.matches("invoice").count(), text.matches("scanned").count()), (1, 1), "{text}");
}
/// Edit text & images on a scanned page: an OCR word (invisible text over the scan) opens for
/// editing on a click, stays open when the pointer leaves it, and its editor hides the scanned
/// word instead of showing it through.
#[test]
fn an_ocr_word_opens_for_editing_and_stays_open() {
use pdfcraft_engine::ocr::PlacedWord;
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("scan.pdf", None, scan()).unwrap();
let id = app.views[0].id;
let ph = app.session.get(id).unwrap().info.pages[0].height as f64;
let words = vec![PlacedWord {
text: "Recognize".into(),
origin: [72.0, ph - 100.0],
across: [80.0, 0.0],
up: [0.0, 14.0],
confidence: None,
source: "ocrs".into(),
}];
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
app.set_option("zoom", "100").unwrap();
assert!(app.execute("edit.edit_text"));
app
});
for _ in 0..60 {
h.run_steps(2);
if !h.state().render_pending() {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
let (r, k) = {
let app = h.state();
let d = app.session.get(app.views[0].id).unwrap();
assert!(d.text_blocks(0).first().is_some_and(|b| b.invisible));
let r = app.views[0].page_screen_rect(0).unwrap();
(r, r.width() / d.info.pages[0].width)
};
// The word, 100 pt from the top of the page.
let p = r.min + egui::vec2(100.0, 93.0) * k;
h.hover_at(p);
h.run_steps(2);
for pressed in [true, false] {
h.event(egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() });
}
h.run_steps(4);
let opened = |h: &Harness<'_, PdfCraftApp>| h.state().views[0].line_editor.as_ref().map(|e| e.text.clone());
assert_eq!(opened(&h).as_deref(), Some("Recognize"));
assert!(h.state().views[0].image_selection.is_none(), "the scan under the word isn't selected");
h.hover_at(r.min + egui::vec2(300.0, 400.0) * k);
h.run_steps(4);
assert_eq!(opened(&h).as_deref(), Some("Recognize"), "moving the pointer away keeps the word open");
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 219 KiB

+1 -1
View File
@@ -20,7 +20,7 @@ Whole sentences that the layers below word — what the accessibility checker fo
The Ukrainian catalog has 2,530 entries, written from the meaning of the English labels using Ukrainian computing vocabulary. Commands use infinitives; fixed-count messages use count-neutral wording. Plural-aware messages use the three [CLDR Ukrainian integer forms](https://www.unicode.org/cldr/charts/48/supplemental/language_plural_rules.html#uk): `1 сторінка`, `2 сторінки`, `5 сторінок`; 11–14 take the third form, while 21 and 22 return to the first and second. Calendar dates use genitive month names (`9 жовтня 2026`), while headings use standalone names (`Жовтень 2026`). Home recommendation cards grow to fit translated text; the toolbar search hint truncates before the shortcut, with the full label available on hover.
The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation.
The Lithuanian catalog has 2,798 entries and three integer plural forms. Calendar headings use standalone month names; dates use genitive forms. Calendar, inspector and password-dialog UI regressions check dynamic labels and preserve document values. A native Lithuanian-speaking contributor has reviewed the translation. The Scan & OCR review strings added afterwards (M10: the OCR Verify screen, the batch options and the engine choice) were written without native review.
The Vietnamese catalog has 2,798 entries and one plural form (`plural_none`), covering the current catalog-key union. Preferences theme `Appearance` uses context `theme` → `Giao diện`; object/comment/signature appearance stays plain `Diện mạo`. Form fields use `Trường`; redaction apply wording states permanent removal. Calendar months are numeric (`Tháng 1`…`12` / `tháng 1`…`12`). A native Vietnamese-speaking contributor has reviewed the translation. Status stays `partial`: no translated help, and engine/OS details stay English inside a translated frame.
+34 -34
View File
@@ -6683,7 +6683,7 @@ milestone = "M10"
status = "shipped"
commands = ["ocr.recognize"]
tools = ["ocr_recognize", "ocr_status"]
evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.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"]
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)."
[[feature]]
@@ -6708,7 +6708,7 @@ status = "shipped"
commands = ["ocr.recognize_batch"]
tools = ["ocr_recognize_files"]
evidence = ["crates/automation/tests/it/automation.rs::ocr_recognize_files_writes_searchable_copies", "crates/ui-egui/tests/it/ocr.rs::recognize_text_in_multiple_files_writes_searchable_copies"]
notes = "Pick PDFs, then an output folder; copies are saved incrementally under the same names, in the background with progress."
notes = "Pick PDFs, then an output folder (the last folder is remembered and reused; Clear asks again); copies are saved incrementally under the same names, in the background with progress. Output names are pre-flighted (a collision aborts before the first page is read), files that already have text are skipped whole only when that option is on (skip_text_files, off by default; Force OCR overrides), and the summary counts recognized words, the skipped bucket, per-file failures, low-confidence notes and what a cancel left unrun."
[[feature]]
id = "ocr.ocr-languages"
@@ -6719,8 +6719,8 @@ milestone = "M10"
status = "partial"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"]
notes = "Language choice exists, but the models read only the Latin alphabet without accents (English)."
evidence = ["crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text", "crates/ocr/src/tesseract.rs::language_table_maps_and_falls_back"]
notes = "The choice feeds both engines: ocrs reads only unaccented Latin (its model table holds English); tesseract maps 12 allow-listed codes (the language enum comes from the engine's own table) but only as far as the installed language packs cover. Recognition quality beyond the tested English set is unproven."
[[feature]]
id = "ocr.searchable-image"
@@ -6731,8 +6731,8 @@ milestone = "M10"
status = "partial"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable"]
notes = "Invisible text (Tr 3) fitted to each word box; the image is not deskewed or downsampled (exact mode only)."
evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/engine/src/ocr.rs::searchable_layer_round_trips_non_win_ansi_words", "crates/engine/src/ocr.rs::preprocessing_flags_flow_through_the_job"]
notes = "Invisible text (Tr 3) fitted to each word box. The page's own image object is left untouched; the raster the engines read can be cleaned first (opt-in rotate/deskew/denoise/binarize), with word boxes mapped back to the original raster. Words beyond WinAnsi are written in Type3 fonts with empty character programs and a ToUnicode CMap, so extraction reads them back while nothing needs a display font (no craft-fonts input, and no character is ever substituted with '?'; those substitutions belong to the editable output). Missing: the downsampled Searchable Image variant (ocr.ocr-downsample)."
[[feature]]
id = "ocr.searchable-image-exact"
@@ -6753,9 +6753,9 @@ area = "I"
tier = "P1"
milestone = "M10"
status = "partial"
commands = ["edit.edit_text"]
tools = ["text_edit"]
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"]
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."
[[feature]]
@@ -6779,8 +6779,8 @@ milestone = "M10"
status = "shipped"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["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/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/it/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/it/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text"]
notes = "Pages that already have text are skipped and reported by default. The dialog's \"Also recognize pages that already have text\" (skip_text_pages false, also for multiple files) reads pictures on such pages (#1028); a recognised word at least half covered by the page's own text (an earlier OCR layer included) is left out and counted (already_on_page). Missing: recognising only the image areas (the whole page is still read, so it takes as long as a scan), and a word in a picture that sits under real text is treated as that text."
evidence = ["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/engine/src/tests.rs::recognize_text_reads_a_picture_on_a_page_that_has_text", "crates/automation/tests/it/automation.rs::ocr_recognize_reads_pictures_on_pages_with_text", "crates/ui-egui/tests/it/ocr.rs::recognize_text_can_read_pictures_on_pages_with_text", "crates/automation/tests/it/automation.rs::ocr_recognize_files_writes_searchable_copies"]
notes = "Text pages are skipped by default; skip_text_pages false or Force OCR reads pictures on them while omitting words overlapping existing text (already_on_page). Batch skip_text_files reports skipped files without writing them; Force OCR overrides both skips. Recognition still reads the whole page, and image text under real text can be omitted."
[[feature]]
id = "ocr.rotation-detection"
@@ -6788,11 +6788,11 @@ title = "Detect and fix page rotation"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/ocr/src/preprocess.rs::quarter_turns_are_detected_corrected_and_mapped_back", "crates/ocr/src/preprocess.rs::realistic_letter_pages_are_left_upright"]
notes = "Opt-in preprocessing: 0/90/180/270 detected from projection-profile banding (conservative: under 32 px, blank, or without a clear margin it is a no-op); word boxes map back to the scanned raster through the exact inverse. Correction is proven at the preprocessing layer and the flag flows through the job (needs the models), but correction of a genuinely rotated page through the workflow is not yet covered by a cited end-to-end test."
[[feature]]
id = "ocr.deskew"
@@ -6800,11 +6800,11 @@ title = "Deskew"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/ocr/src/preprocess.rs::skew_is_estimated_and_straight_pages_are_left_alone", "crates/ocr/src/preprocess.rs::deskew_inverse_maps_off_centre_points_back_onto_the_source_line"]
notes = "Opt-in: skew estimated over +/-5 degrees by a shear-scored projection profile; conservative no-ops (under 64 px, blank, below 0.1 degrees, no clear winner); boxes map back off-centre-exact through the composed inverse. Correction is proven at the preprocessing layer; a cited end-to-end test of a skewed page through the workflow is still owed."
[[feature]]
id = "ocr.despeckle"
@@ -6813,9 +6813,9 @@ area = "I"
tier = "P1"
milestone = "M10"
status = "partial"
commands = ["ocr.enhance_camera", "ocr.enhance"]
tools = ["page_enhance_camera", "page_enhance_scan"]
evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did"]
commands = ["ocr.enhance_camera", "ocr.enhance", "ocr.recognize"]
tools = ["page_enhance_camera", "page_enhance_scan", "ocr_recognize"]
evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/it/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn", "crates/engine/src/scan_clean/tests.rs::despeckle_is_its_own_switch", "crates/automation/tests/it/automation.rs::enhance_scanned_file_cleans_the_scan_in_place_through_tools", "crates/ui-egui/tests/it/scan_clean.rs::enhance_scanned_file_cleans_the_chosen_pages_and_says_what_it_did", "crates/ocr/src/preprocess.rs::denoise_removes_specks_and_keeps_dimensions"]
notes = "Camera images, and scanned pages through Enhance scanned file (ocr.enhance, page_enhance_scan despeckle): isolated dark pixels (at most one dark neighbour) turn white, on grey, RGB and 1-bit scans, in place (the OCR text layer, comments and links stay). Missing: as an option of Recognize text; specks larger than two pixels; CMYK, indexed, JPEG 2000, JBIG2 and CCITT scans and scans inside form XObjects (reported, not changed)."
[[feature]]
@@ -6896,11 +6896,11 @@ title = "Correct recognized text (low-confidence suspects)"
area = "I"
tier = "P2"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.correct"]
tools = ["ocr_words", "ocr_review_apply", "ocr_dictionary"]
evidence = ["crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_uses_recognition_language_instead_of_interface_locale", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_limit_and_normalization_failures_preserve_file_and_review", "crates/ui-egui/src/workflows/ocr_verify.rs::dictionary_undo_persists_and_failed_undo_keeps_history", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_keeps_captured_language_when_the_draft_changes", "crates/automation/tests/it/automation.rs::ocr_review_corrections_keep_boxes_are_guarded_and_apply_once", "crates/automation/tests/it/automation.rs::ocr_dictionary_updates_are_atomic_bounded_and_confined", "crates/automation/tests/it/automation.rs::ocr_retained_reviews_have_eight_document_cap_and_close_releases_slot", "crates/ui-egui/src/workflows/ocr_verify.rs::accept_zero_words_fails_and_a_live_review_applies_once", "crates/ui-egui/src/workflows/ocr_verify.rs::legend_rows_carry_the_classifier_thresholds", "crates/ui-egui/src/workflows/ocr_verify.rs::suggestions_are_near_dictionary_and_page_words", "crates/ui-egui/tests/it/ocr.rs::ocr_verify_screen_controls_keyboard_and_accept", "crates/ui-egui/src/workflows/ocr_verify.rs::accepting_pages_one_by_one_then_the_review_applies_every_page_once", "crates/ui-egui/src/workflows/ocr_verify.rs::rereading_a_region_remaps_marks_and_selects_the_first_new_word"]
notes = "The OCR Verify screen: page list, source page with confidence-coloured word boxes (click-select, drag a region), recognised-text preview, zoom + word inspector; corrections commit on Enter (an empty commit removes), Remove/Restore, +Dictionary (a per-language user file), per-word Verified, suggestions within edit distance 2; F8/Shift+F8 walk the low-confidence suspects, Ctrl+T marks the page reviewed, Ctrl+Tab moves the pane focus; Re-OCR of a region or a page; Accept/Reject entire page; a real Cancel keeps the pages already read; a stale session is refused with its reason; review edits are undoable in the panel; Accept (searchable) is one undoable step through the guard path, and pages accepted one by one each apply once (the guard follows each accepted page); a re-read keeps every review mark on the word it was made for. Headless: ocr_words (retain_review) returns the words and a review handle, ocr_review_apply applies corrections at the stored boxes (an empty one removes the word) through the same stale-document guard, or discards the review, and ocr_dictionary edits the dictionary file. Still missing: suggestions, per-word Verified marks and region re-reads are UI-only, and Acrobat's suspect walk across a whole saved document (outside one review) is not offered."
[[feature]]
id = "ocr.adaptive-compression"
@@ -6932,11 +6932,11 @@ title = "Pluggable OCR engines (ocrs default; tesseract optional)"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.recognize"]
tools = ["ocr_recognize"]
evidence = ["crates/engine/src/ocr.rs::selector_names_the_missing_engine", "crates/ocr/src/tesseract.rs::recognize_runs_the_installed_binary", "crates/ocr/src/merge.rs::rover_vote_always_runs_both_and_degrades_without_a_secondary", "crates/ocr/src/merge.rs::confidence_weighted_runs_the_secondary_on_an_unknown_primary", "crates/ocr/src/merge.rs::an_unknown_primary_word_yields_only_to_a_confident_secondary", "crates/ocr/src/tesseract.rs::an_invalid_configured_program_fails_closed", "crates/ocr/src/tesseract.rs::list_langs_come_from_stderr_when_stdout_has_none", "crates/engine/src/ocr.rs::automatic_defaults_to_the_confidence_weighted_strategy"]
notes = "ocrs (pure Rust, MIT/Apache, CC-BY-SA models fetched by `cargo xtask models`) is the default; an installed tesseract binary runs as an external process (fixed argv, allow-listed language codes, private temp dir, timeout and output cap, a user-validated PDFCRAFT_TESSERACT path or the PATH); Automatic is an ensemble (ROVER and confidence-weighted merge; a failing partner degrades to the primary with a note). Partial, because part of the evidence is conditional: `recognize_runs_the_installed_binary` runs only where tesseract is installed and skips elsewhere, so a build machine without it proves nothing about the process path, the absent-binary paths are exercised only on machines without tesseract, and the process-level tests (timeout, output cap, minimal environment) are Unix-only (fake scripts). What always runs: location validation and the fail-closed rule, the --list-langs parsing (stdout or stderr), the engine selector, the merge rules and the default strategy. Automatic defaults to the confidence-weighted strategy (Primary only would never run the second engine)."
[[feature]]
id = "ocr.ocr-web"
+4 -4
View File
@@ -54,7 +54,7 @@ fn validate(root: &Path) -> Result<()> {
let docs: Documents = toml::from_str(&text).context("parsing LICENSES.toml")?;
let attribution = assets::load(root)?;
let mut problems = check(root, &docs, &attribution);
let ocr_source = std::fs::read_to_string(root.join("crates/ocr/src/lib.rs")).context("reading OCR model filename constants")?;
let ocr_source = std::fs::read_to_string(root.join("crates/ocr/src/ocrs.rs")).context("reading OCR model filename constants")?;
problems.extend(check_required_ocr_metadata(&ocr_source, &attribution)?);
if !problems.is_empty() {
bail!("source licensing errors:\n{}", problems.join("\n"));
@@ -72,7 +72,7 @@ fn check_required_ocr_metadata(source: &str, attribution: &assets::Manifest) ->
let file = declarations.first().and_then(|value| value.strip_suffix("\";"));
let Some(file) = file.filter(|file| declarations.len() == 1 && safe_path(file) && !file.contains('/')) else {
bail!(
"OCR constant {name}: expected one literal filename declaration in crates/ocr/src/lib.rs; update the source validator if its syntax changed"
"OCR constant {name}: expected one literal filename declaration in crates/ocr/src/ocrs.rs; update the source validator if its syntax changed"
);
};
let entries: Vec<_> = attribution.fetched.iter().filter(|f| f.file == file).collect();
@@ -407,7 +407,7 @@ mod tests {
#[test]
fn required_ocr_models_cannot_be_missing() {
let source = include_str!("../../crates/ocr/src/lib.rs");
let source = include_str!("../../crates/ocr/src/ocrs.rs");
let original = assets::load(&assets::root()).unwrap();
assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2);
assert!(check_required_ocr_metadata(source, &original).unwrap().is_empty());
@@ -420,7 +420,7 @@ mod tests {
#[test]
fn required_ocr_models_cannot_be_misclassified() {
let source = include_str!("../../crates/ocr/src/lib.rs");
let source = include_str!("../../crates/ocr/src/ocrs.rs");
let original = assets::load(&assets::root()).unwrap();
assert_eq!(original.fetched.iter().filter(|f| f.kind == "model").count(), 2);
for model in original.fetched.iter().filter(|f| f.kind == "model") {
+5 -1
View File
@@ -39,6 +39,7 @@ pub const SCENES: &[(&str, &str, &str, &[&str])] = &[
"README: layouts",
&["--layout", "two-up", "--mode", "read", "--page", "4", "--theme", "dark", "--notice", "off"],
),
("pdfcraft-ocr-verify", "OCR Verify screen", "README: OCR review", &["--size", "1680x1000", "--ocr-verify", "on"]),
];
const PDF: &str = "dist/demo/pdfcraft-showcase.pdf";
@@ -109,7 +110,10 @@ fn update_attribution(root: &Path) -> Result<()> {
// The home screen shows the ArtCraft mark: say so (it is a Storyteller trademark, not open
// source; AGENTS.md §1.2), so the screenshot's licence is not read as covering it.
let shows_home = scene.is_some_and(|(.., args)| args.windows(2).any(|w| w[0] == "--home" && w[1] == "on"));
let contents = if shows_home {
let shows_ocr = scene.is_some_and(|(.., args)| args.windows(2).any(|w| w[0] == "--ocr-verify" && w[1] == "on"));
let contents = if shows_ocr {
"Shows only PdfCraft UI, its Lucide icons and a synthetic scanned page built from the embedded fonts, with a real recognition (the OCR models) loaded into the Verify screen"
} else if shows_home {
"Shows PdfCraft UI, Lucide icons, the showcase PDF built from the [[fetched]] OFL fonts, and the ArtCraft mark (Storyteller trademark from docs/brand/, not covered by this licence)"
} else {
"Shows only PdfCraft UI, Lucide icons and the showcase PDF built from the [[fetched]] OFL fonts"