mirror of
https://github.com/storytold/pdfcraft.git
synced 2026-11-10 19:22:00 +00:00
Merge remote-tracking branch 'upstream/main' into float-4094
# Conflicts: # crates/annot/src/info.rs
This commit is contained in:
@@ -1,2 +1,7 @@
|
||||
[alias]
|
||||
xtask = "run -p xtask --"
|
||||
|
||||
# WebAssembly SIMD (every current browser has it; there's no runtime detection on wasm). The OCR
|
||||
# engine's kernels (rten-simd) and other vectorised code only use it when compiled in.
|
||||
[target.wasm32-unknown-unknown]
|
||||
rustflags = ["-C", "target-feature=+simd128"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<!-- craft-house-rules:start core source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
<!-- craft-house-rules:start core source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=540e6cea75d12d0710c3dca89658328e81e508ecef102446ca5bb95b7c0b92bb -->
|
||||
## House rules
|
||||
|
||||
These apply in every Craft repository. A later rule may be stricter, not weaker. The source is `standards/house-rules-core.md` in `storytold/craft-contrib`. Propose changes there, not in a copy.
|
||||
@@ -19,7 +19,7 @@ These apply in every Craft repository. A later rule may be stricter, not weaker.
|
||||
14. **Keep pull requests moving.** Concurrent pull requests are fine; unanswered actionable feedback is not. Respond in 7 days, a reminder at 14, closure eligible at 30, from when an actionable request waits on the author. Suggestions and unconfirmed bot findings do not start the clock. A fix, explanation or agreed plan resets it; unrelated activity does not. Waiting on a reviewer or maintainer pauses it and does not count against the author. A maintainer decides closure; it is never automatic. Reuse search results, do not poll, and stop at a rate limit.
|
||||
15. **No agent attribution.** A pull request does not say an agent wrote it. A repository rule may not require that.
|
||||
<!-- craft-house-rules:end -->
|
||||
<!-- craft-house-rules:start rust source=dde09ea31e2e457f5b4653a6e76f7cdf55944bdf sha256=387e0ccb4b49e567f60951d7d6fff7305054c89f43ad812b9993dc7c38528013 -->
|
||||
<!-- craft-house-rules:start rust source=1315c63e17905daf7dbc6dc6f8c49384e415162b sha256=642f46b4ac38cc9e4d10d1ccde19d68cb6bc03d20e4c6236a7535f3047566262 -->
|
||||
## Rust Craft repositories
|
||||
|
||||
These apply to native Craft apps and to shared Rust libraries. Select them by repository type. A repository that merely contains some Rust does not take this block. Rules later in a repository's file may be stricter, but not weaker. The source is `standards/house-rules-rust.md` in [storytold/craft-contrib](https://github.com/storytold/craft-contrib).
|
||||
@@ -40,6 +40,7 @@ Dependencies and assets use SPDX identifiers. An expression is allowed when ever
|
||||
- `BSD-2-Clause`
|
||||
- `BSD-3-Clause`
|
||||
- `Apache-2.0`
|
||||
- `Unicode-3.0`
|
||||
- `OFL-1.1`
|
||||
- `Ubuntu-font-1.0`
|
||||
- `Bitstream-Vera`
|
||||
|
||||
Generated
+18
-3
@@ -1243,7 +1243,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-print"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=787a7a0f972fd1d7a7618fdbeca3a5267a0407ff#787a7a0f972fd1d7a7618fdbeca3a5267a0407ff"
|
||||
source = "git+https://github.com/storytold/craft-libs?rev=7ee35a9fbd47af572d55fd63dbf0333d1f00da17#7ee35a9fbd47af572d55fd63dbf0333d1f00da17"
|
||||
dependencies = [
|
||||
"craft-winprint-gdi",
|
||||
"log",
|
||||
@@ -1335,7 +1335,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "craft-winprint-gdi"
|
||||
version = "0.1.0"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=417ac3c4d14d3da5485a99c7d5ce8f05664ec312#417ac3c4d14d3da5485a99c7d5ce8f05664ec312"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=64e7afd4d1483512385012c340ecaaaf0f3beb7f#64e7afd4d1483512385012c340ecaaaf0f3beb7f"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
@@ -4277,6 +4277,7 @@ dependencies = [
|
||||
"hayro-syntax",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-geom",
|
||||
"pdfcraft-model",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
@@ -4539,10 +4540,22 @@ dependencies = [
|
||||
"pdfcraft-fonts",
|
||||
"png 0.18.1",
|
||||
"rten",
|
||||
"serde_json",
|
||||
"sha2 0.11.1",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-ocr-worker"
|
||||
version = "0.18.1"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"pdfcraft-ocr",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfcraft-optimize"
|
||||
version = "0.18.1"
|
||||
@@ -4637,6 +4650,7 @@ dependencies = [
|
||||
"pdfcraft-filters",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-forms",
|
||||
"pdfcraft-geom",
|
||||
"pdfcraft-model",
|
||||
"pdfcraft-render",
|
||||
"pdfcraft-testkit",
|
||||
@@ -4660,6 +4674,7 @@ dependencies = [
|
||||
"lopdf",
|
||||
"pdfcraft-cos",
|
||||
"pdfcraft-fonts",
|
||||
"pdfcraft-geom",
|
||||
"serde",
|
||||
"skrifa 0.44.0",
|
||||
"thiserror 2.0.21",
|
||||
@@ -8247,7 +8262,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "winprint"
|
||||
version = "0.2.1"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=5f80b9baf6b58937c401656634e42d33f00ddf49#5f80b9baf6b58937c401656634e42d33f00ddf49"
|
||||
source = "git+https://github.com/storytold/winprint.rs?rev=51934db382f225454cf152a8657eb03789be63c4#51934db382f225454cf152a8657eb03789be63c4"
|
||||
dependencies = [
|
||||
"flate2",
|
||||
"fmt-derive",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1
|
||||
# the commit in `craft-i18n.rev`). Already in the tree through craft-ui.
|
||||
craft-i18n = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# Shared system spooler; document composition remains in pdfcraft-print.
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "7ee35a9fbd47af572d55fd63dbf0333d1f00da17" }
|
||||
# Whether a path stored in a document may be opened (GoToR link targets).
|
||||
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "787a7a0f972fd1d7a7618fdbeca3a5267a0407ff" }
|
||||
# craft-update: app-specific pretend versions apply to startup and manual checks.
|
||||
|
||||
+17
@@ -1,5 +1,11 @@
|
||||
# PdfCraft roadmap
|
||||
|
||||
> **Last reviewed:** 2026-11-08 | **Last updated:** 2026-11-08 | **Change:** patch (M10 #6514: refresh craft-print to 7ee35a9f, PDF loader to 51934db3 and public GDI kept at 64e7afd4 after #605 merged; preserve crash fix and stream-test repairs; new-graph CI pending; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M10: file-backed Windows PDF loading without whole-job buffering or per-chunk async waits; approved dependency chain retains #5006; physical output still unqualified; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-05 | **Last updated:** 2026-11-05 | **Change:** patch (M7 #2964: directly edit Thai source text inside supported Form XObjects; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged)
|
||||
@@ -233,7 +239,10 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
## Progress log
|
||||
- **2026-10-13 (M8, redact by regex):** `redact_mark` takes `regex` (caller expression, linear-time engine, length/size/nesting caps, empty-matching refused) with optional `validate` (luhn, mod97, iban). Headless only; issue #1886.
|
||||
|
||||
- **2026-11-07 (M7, #2964 / #3185):** Supported Form text can be prepared for direct editing; groups left grouped (unsafe compositing, text nested past eight levels) are reported. Legacy Thai keeps the original font programs, Regular/Bold and text settings, also across same-face subsets; the narrow THSarabunPSK map repair is checked glyph by glyph against the font program; cross-family replacement is explicit. Builds on #3067 and #2811. Unsafe groups and mixed contextual styles remain partial; estimates unchanged.
|
||||
- **2026-10-28 (M10, #4119 partial):** Scan & OCR ▸ Enhance scanned file cleans scanned pages in place: background removal (level 0–100, as the camera clean-up), edge shadow removal (book gutters, copier borders) and despeckle. Each scan image is decoded, cleaned and written back into its own object at the same size and colour space (JPEG stays JPEG, others lossless, 1-bit stays 1-bit), so the OCR text layer, comments and links stay; one undo step, shared images once, unsupported images (CMYK, indexed, JPEG 2000, JBIG2, CCITT, broken data) reported. `page_enhance_scan` drives it headlessly. Not yet an option of Recognize text. Estimates unchanged.
|
||||
- **2026-11-07 (M10, `ocr.ocr-web`, port onto #977):** Web OCR rebuilt on main's engines and preprocessing (#977). `OcrJob::run` is now `OcrJob::steps` a page at a time: the page renders, crops to the region and cleans each page, a Web Worker runs ocrs on the result, and `OcrShot::place` maps the words back through the region and preprocessing. Every strategy reads like this when only one engine is present, and the web build has only ocrs. The downloaded models are installed into the engine cache (`ocr::install`), so the Verify screen and the engine-status checks see them too. The batch keeps main's skipped-files bucket, low-confidence notes and cancel. The translations moved to craft-i18n (storytold/craft-i18n#141). The Verify screen starts the model download itself and runs once the models land; before, on a fresh page it failed with the desktop's "set PDFCRAFT_MODELS" message. Known limits: the cleaning steps, and the Verify screen's Run OCR and re-reads, run on the page. Estimates unchanged.
|
||||
|
||||
- **2026-11-03 (M11, shared JBIG2 dictionary):** With Monochrome Images set to JBIG2, Optimize has up to 16 JBIG2 images share one symbol dictionary (`/JBIG2Globals`), the letters found on more than one page stored once, when that is smaller (`pdfcraft_filters::encode_jbig2_shared`). Eight pages of 10 pt text rendered at 300 dpi take 41% less in Helvetica and 42% in Times than coded alone, 10% in Courier; groups of 16 save 45%, of 32 only 47%. Only images redaction can store again without the dictionary share one (image masks and DeviceGray named directly, at most 64 Mi pixels), and redacting one stores the rest of its group so, which the group bounds to 15.
|
||||
|
||||
- **2026-11-03 (M5, #5850 review):** Resolve indirect IT when recognizing typewriter contents and Fill & Sign marks. Comment listing and property lookup retain their existing direct-Subtype behavior. A real double-click Edit composer preserves the typewriter’s text as written; ordinary comment edits keep trimming. Regressions cover direct Subtype with indirect intent, save/reopen, raw posted contents and same-index comments on different pages. The existing live-movement test now commits its setup with an outside click rather than Enter. Estimates unchanged.
|
||||
@@ -270,6 +279,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
|
||||
- **2026-10-27 (M2, PR #4172 module-split conflicts):** Integrated main 1dd2737b, retaining viewer-only CJK fallback in split session modules and Combine thumbnail workers, and moving renderer regressions to the new test module. Validation: 798 scoped tests passed (371 engine, 412 renderer, 15 Combine; two existing engine tests ignored); scoped all-target Clippy with -D warnings, formatting, layers, assets, parity and renderer/engine WASM checks passed. Estimates unchanged.
|
||||
|
||||
- **2026-10-27 (M10, `ocr.ocr-web`, rebase fix):** The web build loads OCR models only when their SHA-256 matches `ATTRIBUTION.toml`. With ONNX loading now on, rten's parser spins forever on some bytes that aren't a model (a corrupt or wrong download), so a bad file now fails at once instead of hanging a worker.
|
||||
- **2026-10-27 (M10, OCR models):** Model staging downloads and verifies the ONNX pair after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, retains complete RTEN fallback and preserves directory precedence.
|
||||
|
||||
- **2026-10-26 (M2, PR #4172):** Reconciled current main, preserving its Japanese serif/weight selection and both branches' progress records. Installed CJK viewer and preview fallback remains display-only; estimates unchanged.
|
||||
@@ -281,6 +291,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
- **2026-10-25 (web, same-document host mode):** A page that loads the PdfCraft web module into its own document (no iframe) can opt into the host bridge with `data-host="page"` on the app's canvas — the exact value `page`; any other value keeps the normal app with downloads, unless a same-origin framed `?host=parent` selects the parent bridge (unframed or cross-origin, `?host=parent` also keeps the normal app). It is the `?host=parent` bridge with the transport swapped: the same five messages travel as non-bubbling `CustomEvent`s on the canvas (`detail` holds the parent messages' fields without `type`; the host answers with a `pdfcraft:open` event), so saves reach the host instead of downloading and dirty and start-up-failure reporting are unchanged. Unit tests cover the opt-in rule; no parity or estimate changes.
|
||||
- **2026-10-24 (M10, #4158 partial):** Word, HTML, RTF and PowerPoint export keep a block that continues another on its line (an italic case name, a link, a superscript) as a run in its own style inside that paragraph, with the source's word space, instead of a paragraph of its own. Style changes that wrap onto the next line still split paragraphs (the reading blocks keep style boundaries). Estimates unchanged.
|
||||
- **2026-10-24 (M3, window move on Wayland, #3970 follow-up):** After a move or resize by PdfCraft's own title bar or edges, the button release given back to egui goes before the pointer leaving the window, so hover no longer sticks at the old place until the pointer moves. Found by CodeRabbit in review of #3970; a unit test covers it. Estimates unchanged.
|
||||
- **2026-10-26 (M10, `ocr.ocr-web`, review fix):** On the web, Edit on a scanned page starts the OCR model download, and the page is read once the models load; it was taken for no scan and left unread until the document changed.
|
||||
- **2026-10-24 (M10, `ocr.ocr-web`, review fix):** On the web, single-file and multiple-file OCR running at once no longer lose a page: the workers' answers go to the run that sent each page, instead of to whichever run collected first, and a cancelled run's late answers are let go. Regression tests cover both start orders and cancellation. Files chosen for Recognize Text in Multiple Files before the models have downloaded now wait for them instead of having to be chosen again, and idle workers close on time even when nothing is happening on the page, a full minute after their last page comes back rather than after it was handed out (checked by hand in Firefox); estimates unchanged.
|
||||
- **2026-10-24 (M14, #4253):** The annotation quick bar remembers Keep tool selected across restarts, including unpinning. Old and malformed settings keep safe defaults; the opt-in UI control channel reads and changes the same preference. Estimates unchanged.
|
||||
- **2026-10-22 (M7, #2517):** A partial rewrite of the last paragraph closes its marker after that paragraph's last `ET`, so a row left in the source stays in the paragraph. When the next paragraph shares the text object, the marker closes before that neighbour instead of after the shared `ET`. The extra marker is not added across two marked-content regions. Estimates unchanged.
|
||||
- **2026-10-15 (M7, #2517):** A blank line between paragraphs stays a paragraph break. An explicit `TL` is the measured baseline gap. Without one, three or more rows at a repeated step stay one paragraph, and a single wider gap stays a break. Adding a row, even when an earlier row is left in the source, does not absorb the next paragraph on a later edit. Estimates unchanged.
|
||||
@@ -560,6 +572,8 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md
|
||||
Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
- **2026-10-28 (M10, #1595):** Export to Markdown: `doc_export_office` with a `.md` or `.markdown` path writes CommonMark with GitHub pipe tables (headings, paragraphs with bold and italic, tables with their first row as the header, a rule between pages); text that Markdown would read as markup is escaped. Images, lists and links are not in the export's layout model and are left out. No menu item yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** OCR in the browser, partly. The web zip carries the text recognition models in `models/` beside `index.html`; the first time Recognize Text is needed the web build downloads them (about 12 MB, with a spinner, an error and a retry). Pages are rendered on the page and read in Web Workers (up to two at once, so the page keeps responding), built with WebAssembly SIMD: 8.5 s a page instead of 20.5 s in wasmtime, still about 17× native. Where workers can't start, the page reads the pages itself. No automated browser test yet. Estimates unchanged.
|
||||
- **2026-10-12 (M10, `ocr.ocr-web`):** Web OCR shipped: reading scanned pages in Web Workers was checked by hand in a browser (4-page synthetic scan). Still no automated browser test, and a page reads about 17× slower than native.
|
||||
- **2026-10-12 (M3, GNOME title bar, #553):** On GNOME's Wayland session, whose compositor draws no window frames (it offers no `zxdg_decoration_manager_v1`), PdfCraft no longer shows winit's fallback title bar: it turns the frame off and the tab strip becomes the header bar, as in GNOME's own apps. Minimize, maximize/restore and close are drawn in Adwaita's style, in the order and on the side GNOME's `button-layout` names (Fedora's default shows only close); the window's edges and corners resize it, and a hairline outlines it while it isn't maximized. Preferences ▸ Use system title bar (shown only there) gives the window back winit's frame at once, without a restart (part of #1001), and `PDFCRAFT_TITLEBAR=app|system` overrides the choice. KDE, Sway, Hyprland, COSMIC and X11 keep the frame their desktop draws. Not done: right-clicking the header bar doesn't open GNOME's window menu (egui can't ask for it), the window has no shadow or rounded corners, the resize edges lie just inside the window rather than outside it, and Windows still uses its system frame (the rest of #1001). Estimates unchanged.
|
||||
- **2026-10-12 (M0, build label):** A local or patched build can carry a label, set at compile time with `PDFCRAFT_BUILD_LABEL`: `pdfcraft --version`, `pdfcraft-cli --version`, About and the log show `0.7.1 (titlebar)`, and `packaging/linux/package.sh` names the files `pdfcraft-0.7.1-titlebar-…` and makes the .rpm/.deb release `1.titlebar`, which upgrades over the release of the same version (and the next release over it). The version itself is unchanged, so the update check still compares it with published releases. Unlabelled builds are as before. Estimates unchanged.
|
||||
- **2026-10-11 (M6, #1208):** Dropdown and list-box Field Properties expose the existing Left/Center/Right alignment picker. Existing alignment stays intact until changed; cancel, undo/redo and save/reopen are covered through the real UI without changing options, values or flags. Estimates unchanged.
|
||||
@@ -795,6 +809,9 @@ Newest first: the date, what moved, and the new overall estimate.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-08 | patch | M10 #6514/#636: retain main's public GDI Git revision (64e7afd4) to preserve downstream Rust type identity, update the internal PDF loader independently, and refresh the application dependency/lockfile. New-graph CI pending; estimates unchanged. |
|
||||
| 2026-11-08 | patch | M10 #6514: refresh the craft-print and winprint/GDI pins after #605 merged; retain the Direct2D crash fix and stream-test cleanup repairs, record single-page HUAWEI output with its diagnostic limits, and rerun CI for the new dependency graph. Estimates unchanged. |
|
||||
| 2026-11-07 | patch | M10 #6514: load Windows PDF print jobs on demand through a read-only file stream, avoiding StorageFile access denial, whole-file buffering and per-chunk async waits; approved dependencies retain the #5006 crash fix. Regression tests pass; physical output remains unqualified; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
| 2026-11-01 | patch | M7 #5657: header/footer, watermark and background operations commit all selected pages together; later-page errors preserve marks, content, shared/inherited resources and pending edits; estimates unchanged. |
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "pdfcraft-ocr-worker"
|
||||
description = "PdfCraft in the browser: the Web Worker that recognises text (wasm32; trunk builds it with pdfcraft-web)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish = false
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
pdfcraft-ocr = { workspace = true }
|
||||
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["DedicatedWorkerGlobalScope", "MessageEvent"] }
|
||||
@@ -0,0 +1,101 @@
|
||||
//! PdfCraft in the browser: the Web Worker that recognises text, so reading a scanned page doesn't
|
||||
//! stop the page responding and several pages are read at once. Trunk builds it alongside
|
||||
//! `pdfcraft-web` (see apps/pdfcraft-web/index.html); the page starts and feeds the workers
|
||||
//! (crates/ui-egui/src/workflows/ocr_ui.rs).
|
||||
//!
|
||||
//! Messages are plain objects with a `type`:
|
||||
//!
|
||||
//! - `ready` (to the page): the worker is listening; send the models now.
|
||||
//! - `models` (from the page): `{ detection, recognition }`, each a `Uint8Array` holding a model
|
||||
//! file. Answered with `loaded`, or `failed` with an `error`.
|
||||
//! - `page` (from the page): `{ id, width, height, pixels }`, the RGBA pixels of a rendered page
|
||||
//! as a `Uint8Array`. Answered with `lines` `{ id, lines }`, the recognised lines as
|
||||
//! `pdfcraft_ocr::lines_to_json` writes them, or `failed` `{ id, error }`.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn main() {
|
||||
use wasm_bindgen::{JsCast, closure::Closure};
|
||||
|
||||
let Ok(scope) = js_sys::global().dyn_into::<web_sys::DedicatedWorkerGlobalScope>() else {
|
||||
return;
|
||||
};
|
||||
let reply_to = scope.clone();
|
||||
let mut ocr = None;
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
|
||||
let _ = reply_to.post_message(&worker::handle(&mut ocr, &e.data()));
|
||||
});
|
||||
scope.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
// The handler lives as long as the worker.
|
||||
on_message.forget();
|
||||
let _ = scope.post_message(&worker::message(&[("type", "ready".into())]));
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn main() {
|
||||
eprintln!("pdfcraft-ocr-worker runs in a browser: trunk builds it with pdfcraft-web");
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod worker {
|
||||
use pdfcraft_ocr::Ocr;
|
||||
use wasm_bindgen::{JsCast, JsValue};
|
||||
|
||||
/// The models are about 12 MB together.
|
||||
const MAX_MODEL_BYTES: u32 = 64 << 20;
|
||||
/// The renderer keeps pages far smaller; anything larger isn't a page.
|
||||
const MAX_PIXEL_BYTES: u64 = 1 << 30;
|
||||
|
||||
pub fn message(fields: &[(&str, JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in fields {
|
||||
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
/// Answer one message from the page.
|
||||
pub fn handle(ocr: &mut Option<Ocr>, data: &JsValue) -> JsValue {
|
||||
let get = |k: &str| js_sys::Reflect::get(data, &k.into()).unwrap_or(JsValue::UNDEFINED);
|
||||
let id = get("id");
|
||||
let reply = match get("type").as_string().as_deref() {
|
||||
Some("models") => load(&get("detection"), &get("recognition")).map(|o| {
|
||||
*ocr = Some(o);
|
||||
message(&[("type", "loaded".into())])
|
||||
}),
|
||||
Some("page") => match ocr.as_ref() {
|
||||
None => Err("the text recognition models aren't loaded".into()),
|
||||
Some(o) => recognize(o, &get("width"), &get("height"), &get("pixels"))
|
||||
.map(|lines| message(&[("type", "lines".into()), ("id", id.clone()), ("lines", lines.into())])),
|
||||
},
|
||||
_ => Err("unknown message".into()),
|
||||
};
|
||||
reply.unwrap_or_else(|error| message(&[("type", "failed".into()), ("id", id), ("error", error.into())]))
|
||||
}
|
||||
|
||||
fn bytes(v: &JsValue, max: u64) -> Result<Vec<u8>, String> {
|
||||
let a = v.dyn_ref::<js_sys::Uint8Array>().ok_or("expected a Uint8Array")?;
|
||||
if u64::from(a.length()) > max {
|
||||
return Err(format!("{} bytes is too large", a.length()));
|
||||
}
|
||||
Ok(a.to_vec())
|
||||
}
|
||||
|
||||
fn load(detection: &JsValue, recognition: &JsValue) -> Result<Ocr, String> {
|
||||
let max = u64::from(MAX_MODEL_BYTES);
|
||||
Ocr::from_bytes(bytes(detection, max)?, bytes(recognition, max)?).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn recognize(ocr: &Ocr, width: &JsValue, height: &JsValue, pixels: &JsValue) -> Result<String, String> {
|
||||
let side = |v: &JsValue| v.as_f64().filter(|v| v.fract() == 0.0 && (1.0..=f64::from(u32::MAX)).contains(v)).map(|v| v as u32);
|
||||
let (Some(w), Some(h)) = (side(width), side(height)) else {
|
||||
return Err("the page size is not a positive whole number".into());
|
||||
};
|
||||
let px = bytes(pixels, MAX_PIXEL_BYTES)?;
|
||||
if u64::from(w).checked_mul(u64::from(h)).and_then(|n| n.checked_mul(4)) != Some(px.len() as u64) {
|
||||
return Err("the pixels don't match the page size".into());
|
||||
}
|
||||
ocr.recognize(&px, w, h).map(|lines| pdfcraft_ocr::lines_to_json(&lines)).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
@@ -9,3 +9,10 @@ filehash = true
|
||||
[serve]
|
||||
addresses = ["127.0.0.1"]
|
||||
port = 8766
|
||||
|
||||
# Trunk leaves the OCR worker's files unhashed and unnamed in index.html: hash them and name the
|
||||
# worker for the page (xtask/src/web.rs).
|
||||
[[hooks]]
|
||||
stage = "post_build"
|
||||
command = "cargo"
|
||||
command_arguments = ["xtask", "web-worker-link"]
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>PdfCraft</title>
|
||||
<link data-trunk rel="rust" data-wasm-opt="z" />
|
||||
<!-- Recognises text off the page's thread (crates/ui-egui/src/workflows/ocr_web.rs). -->
|
||||
<link data-trunk rel="rust" href="../pdfcraft-ocr-worker/Cargo.toml" data-type="worker" data-loader-shim data-bindgen-target="web" data-wasm-opt="3" />
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; overflow: hidden; background: #f1f1f3; }
|
||||
canvas { width: 100%; height: 100%; display: block; outline: none; }
|
||||
|
||||
@@ -48,7 +48,7 @@ fn main() {
|
||||
});
|
||||
}
|
||||
// eframe never raises a close request on a browser reload or tab close, so `guard_quit` never
|
||||
// prompts; ask the browser to confirm instead while a document has unsaved work (#812).
|
||||
// prompts; ask the browser to confirm instead while a document has unsaved work or an OCR review is open (#812).
|
||||
warn_before_unload(app.unsaved_flag.clone());
|
||||
// Host modes (`host` below, over `craft-web-host`): `?host=parent`, a
|
||||
// same-origin page embedding PdfCraft in an iframe (such as the Nextcloud
|
||||
|
||||
@@ -11,6 +11,8 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# The usable-quad rule `/QuadPoints` readers share.
|
||||
pdfcraft-geom = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
//! Appearance streams (§12.5.5) for the comment types PdfCraft creates, execution plan M5.2.
|
||||
//!
|
||||
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary alone, so the
|
||||
//! same code serves new comments and restyled ones. Drawings are in page space with
|
||||
//! [`build`] draws a normal appearance (`/AP /N`) from the annotation dictionary and the
|
||||
//! objects it points at, so the same code serves new comments and restyled ones. Drawings are in page space with
|
||||
//! `/BBox = /Rect` (identity matrix), except note icons, which are drawn in a 20 × 20 box.
|
||||
//! It returns `None` for anything it cannot draw faithfully (cloudy borders, unknown line
|
||||
//! endings, indirect geometry), so callers never replace an appearance with a worse one.
|
||||
//! endings), so callers never replace an appearance with a worse one.
|
||||
//!
|
||||
//! The note icons are PdfCraft's own drawings (AGENTS.md §1). Text boxes use the standard
|
||||
//! Helvetica font with WinAnsi encoding; line breaking uses [`text_width`], an approximation of
|
||||
@@ -14,7 +14,7 @@ use pdfcraft_cos::{Dict, Object, PdfString, Stream};
|
||||
use pdfcraft_fonts::{first_undrawable, literal, win_ansi};
|
||||
pub use pdfcraft_fonts::{helvetica_width as text_width, wrap};
|
||||
|
||||
use crate::{NOTE_SIZE, Rgb, n};
|
||||
use crate::{Document, NOTE_SIZE, Rgb, n};
|
||||
|
||||
mod symbols;
|
||||
|
||||
@@ -166,6 +166,18 @@ fn nums(d: &Dict, key: &[u8]) -> Option<Vec<f64>> {
|
||||
d.get(key)?.as_array()?.iter().map(|o| o.as_f64()).collect()
|
||||
}
|
||||
|
||||
/// The usable `/QuadPoints` quads, by the rule redaction apply reads them with
|
||||
/// ([`pdfcraft_geom::quad_points::usable_quads`]): a malformed or non-finite quad is not drawn (it
|
||||
/// would write `NaN` into the stream) and the others still are. The array and its entries are
|
||||
/// resolved first, as the other readers resolve them, so an indirect quad is drawn too. `None`
|
||||
/// when none is usable.
|
||||
fn quads(context: &AppearanceContext<'_>) -> Option<Vec<[f64; 8]>> {
|
||||
let doc = context.doc;
|
||||
let q =
|
||||
pdfcraft_geom::quad_points::usable_quads(doc.resolve(context.dict.get(b"QuadPoints")?).as_array()?.iter().map(|x| doc.resolve(x).as_f64()));
|
||||
(!q.is_empty()).then_some(q)
|
||||
}
|
||||
|
||||
/// `/C`-style colour arrays: none (transparent), gray, RGB or CMYK.
|
||||
fn color(d: &Dict, key: &[u8]) -> Option<Option<Rgb>> {
|
||||
let c = match d.get(key) {
|
||||
@@ -448,6 +460,7 @@ fn caret_layout(d: &Dict, rect: [f64; 4]) -> ([f64; 4], Option<(f64, f64, f64)>)
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
struct AppearanceContext<'a> {
|
||||
doc: &'a Document,
|
||||
dict: &'a Dict,
|
||||
subtype: &'a [u8],
|
||||
rect: [f64; 4],
|
||||
@@ -484,8 +497,8 @@ impl AppearanceDrawing {
|
||||
/// Draw the normal appearance of an annotation, or `None` if this subtype/variant isn't supported.
|
||||
/// Text the standard fonts can't draw comes out as `?`: [`undrawable_text`] says which, and
|
||||
/// [`crate::set_appearance`] marks the annotation with it (`pdfcraft_fonts::UNDRAWN_MARK`).
|
||||
pub fn build(d: &Dict) -> Option<Stream> {
|
||||
let context = appearance_context(d)?;
|
||||
pub fn build(doc: &Document, d: &Dict) -> Option<Stream> {
|
||||
let context = appearance_context(doc, d)?;
|
||||
let drawing = AppearanceDrawing::new(&context);
|
||||
match context.subtype {
|
||||
b"Text" => return draw_icon(&context, "Note", [1.0, 0.82, 0.0], drawing),
|
||||
@@ -499,7 +512,7 @@ pub fn build(d: &Dict) -> Option<Stream> {
|
||||
finish_appearance(&context, drawing)
|
||||
}
|
||||
|
||||
fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
fn appearance_context<'a>(doc: &'a Document, d: &'a Dict) -> Option<AppearanceContext<'a>> {
|
||||
// Imported measurement appearances may include leaders, captions and formatting that
|
||||
// this builder cannot reproduce. Preserve them rather than silently losing detail.
|
||||
if d.contains(b"Measure") && !d.contains(b"PCMeasureValue") {
|
||||
@@ -514,6 +527,7 @@ fn appearance_context(d: &Dict) -> Option<AppearanceContext<'_>> {
|
||||
return None;
|
||||
}
|
||||
Some(AppearanceContext {
|
||||
doc,
|
||||
dict: d,
|
||||
subtype,
|
||||
rect,
|
||||
@@ -561,13 +575,12 @@ fn finish_appearance(context: &AppearanceContext<'_>, mut drawing: AppearanceDra
|
||||
}
|
||||
|
||||
fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
|
||||
let d = context.dict;
|
||||
let subtype = context.subtype;
|
||||
let stroke = context.stroke;
|
||||
let c = &mut drawing.content;
|
||||
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
|
||||
let q = quads(context)?;
|
||||
let col = stroke?;
|
||||
for quad in q.as_chunks::<8>().0.iter() {
|
||||
for quad in q.iter() {
|
||||
let p = |i: usize| (quad[2 * i], quad[2 * i + 1]);
|
||||
let (p1, p2, p3, p4) = (p(0), p(1), p(2), p(3));
|
||||
let h = (p1.0 - p3.0).hypot(p1.1 - p3.1);
|
||||
@@ -608,14 +621,13 @@ fn draw_markup(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing)
|
||||
}
|
||||
|
||||
fn draw_redaction(context: &AppearanceContext<'_>, drawing: &mut AppearanceDrawing) -> Option<()> {
|
||||
let d = context.dict;
|
||||
let stroke = context.stroke;
|
||||
let c = &mut drawing.content;
|
||||
// While marked: the outline of each area (the fill comes when applied).
|
||||
let q = nums(d, b"QuadPoints").filter(|q| !q.is_empty() && q.len() % 8 == 0)?;
|
||||
let q = quads(context)?;
|
||||
let col = stroke.unwrap_or([0.89, 0.13, 0.13]);
|
||||
c.push_str(&format!("{}1 w\n", rg_stroke(col)));
|
||||
for quad in q.as_chunks::<8>().0.iter() {
|
||||
for quad in q.iter() {
|
||||
c.push_str(&format!(
|
||||
"{} {} m {} {} l {} {} l {} {} l h S\n",
|
||||
n(quad[0]),
|
||||
|
||||
@@ -40,7 +40,7 @@ pub(crate) fn draw_appearance(doc: &mut Document, r: ObjRef) -> Result<(), Annot
|
||||
/// Draw the appearance for an annotation dictionary: the expensive half of [`set_appearance`].
|
||||
/// Pure from the dictionary and the objects it points at, so [`AppearanceCache`] can reuse it.
|
||||
fn build_appearance(doc: &Document, d: &Dict) -> Option<Stream> {
|
||||
let mut stream = appearance::build(d)?;
|
||||
let mut stream = appearance::build(doc, d)?;
|
||||
// Picture-stamp restyling must retain the placement's page-axis correction.
|
||||
if d.get(b"PCPicture").is_some() {
|
||||
let matrix = d.get(b"AP").and_then(|ap| {
|
||||
|
||||
@@ -120,7 +120,16 @@ pub fn summaries(doc: &Document) -> Vec<Summary> {
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
let quads = nums(b"QuadPoints").as_chunks::<8>().0.to_vec();
|
||||
// Only usable quads, by the rule redaction apply reads them with, so a mark's outline
|
||||
// is the area apply removes (and with none, the `/Rect` both fall back to).
|
||||
let quads: Vec<[f32; 8]> = d
|
||||
.get(b"QuadPoints")
|
||||
.map(|o| doc.resolve(o))
|
||||
.map(|a| {
|
||||
let entries = a.as_array().into_iter().flatten().map(|x| doc.resolve(x).as_f64());
|
||||
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|q| q.map(|v| v as f32)).collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
// Keep in step with `pdfcraft_render::inspect`: a callout gives its points only with a
|
||||
// readable text box, without which they can't be changed either.
|
||||
let callout = (subtype == "FreeText").then(|| callout_line(doc, d).zip(callout_text_box(doc, d))).flatten();
|
||||
@@ -264,7 +273,7 @@ pub fn props(doc: &Document, page: usize, index: usize) -> Option<Props> {
|
||||
width,
|
||||
icon: (subtype == "Text").then(|| d.name(b"Name").and_then(|n| NoteIcon::from_name(&String::from_utf8_lossy(n))).unwrap_or(NoteIcon::Note)),
|
||||
modified: text_value(doc, d, b"M"),
|
||||
restylable: appearance::build(d).is_some(),
|
||||
restylable: appearance::build(doc, d).is_some(),
|
||||
locked: annotation_flags(doc, d) & FLAG_LOCKED != 0,
|
||||
fillable: fillable(&subtype),
|
||||
fill: fill_of(d),
|
||||
|
||||
@@ -804,7 +804,7 @@ pub fn set_style(doc: &mut Document, page: usize, index: usize, change: StyleCha
|
||||
let d = annot_dict(doc, r);
|
||||
let subtype = String::from_utf8_lossy(d.name(b"Subtype").unwrap_or_default()).into_owned();
|
||||
// Check before changing anything: a stale appearance would contradict the new style.
|
||||
if appearance::build(&d).is_none() {
|
||||
if appearance::build(doc, &d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype.clone()));
|
||||
}
|
||||
if color.is_some_and(|c| !finite(&c)) || opacity.is_some_and(|o| !o.is_finite()) || width.is_some_and(|w| !w.is_finite()) {
|
||||
@@ -965,7 +965,7 @@ pub fn set_fill(doc: &mut Document, page: usize, index: usize, fill: Option<Rgb>
|
||||
return Err(AnnotError::Invalid("only rectangles, ovals, polygons and text boxes can be filled".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new fill.
|
||||
if appearance::build(&d).is_none() {
|
||||
if appearance::build(doc, &d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
if fill.is_some_and(|c| !finite(&c)) {
|
||||
@@ -1006,7 +1006,7 @@ pub fn set_part_opacity(doc: &mut Document, page: usize, index: usize, fill: Opt
|
||||
return Err(AnnotError::Invalid("invalid opacity".into()));
|
||||
}
|
||||
// Check before changing anything: a stale appearance would contradict the new opacity.
|
||||
if appearance::build(&d).is_none() {
|
||||
if appearance::build(doc, &d).is_none() {
|
||||
return Err(AnnotError::Unsupported(subtype));
|
||||
}
|
||||
refuse_foreign_text(doc, &d)?;
|
||||
|
||||
+97
-11
@@ -1350,6 +1350,12 @@ fn the_eraser_cuts_strokes_and_removes_empty_drawings() {
|
||||
assert!(!list(&doc, 0).iter().any(|d| d.name(b"Subtype") == Some(b"Ink")));
|
||||
}
|
||||
|
||||
/// A document holding only a catalog, for appearance builds from a bare dictionary whose
|
||||
/// values are all direct.
|
||||
fn bare_doc() -> Document {
|
||||
document_of(&[b"<< /Type /Catalog /Pages << /Type /Pages /Kids [] /Count 0 >> >>"])
|
||||
}
|
||||
|
||||
/// A one-page document from object bodies (object 1 is the catalog, 3 the page).
|
||||
fn document_of(objs: &[&[u8]]) -> Document {
|
||||
let mut out = b"%PDF-1.7\n".to_vec();
|
||||
@@ -1445,7 +1451,7 @@ fn malformed_optional_interior_color_does_not_hide_shape_strokes() {
|
||||
polyline.set(b"IC".to_vec(), malformed);
|
||||
|
||||
for (name, annotation) in [("Square", square), ("Circle", circle), ("Polygon", polygon), ("Line", line), ("PolyLine", polyline)] {
|
||||
assert!(appearance::build(&annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke");
|
||||
assert!(appearance::build(&bare_doc(), &annotation).is_some(), "malformed optional /IC must not hide a valid {name} stroke");
|
||||
}
|
||||
|
||||
let doc = document_of(&[
|
||||
@@ -1540,7 +1546,7 @@ fn a_stamp_without_an_appearance_draws_the_stamp_it_names() {
|
||||
// A stamp's own artwork is never replaced, and the drawing is for display only: edits
|
||||
// (restyling, flattening) still find nothing they may write.
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /AP << /N 9 0 R >>")).is_none());
|
||||
assert!(appearance::build(&stamp_dict("/Name /Draft")).is_none());
|
||||
assert!(appearance::build(&bare_doc(), &stamp_dict("/Name /Draft")).is_none());
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /PCStamp true")).is_none(), "PdfCraft's own stamps are drawn by `build`");
|
||||
assert!(appearance::display_stamp(&stamp_dict("/Name /Draft /Rect [5 5 5 50]")).is_none(), "no width");
|
||||
}
|
||||
@@ -1643,8 +1649,8 @@ fn every_line_ending_is_drawn() {
|
||||
assert_eq!(call.name(b"LE"), Some(b"Diamond".as_slice()));
|
||||
assert!(ap_content(&doc, call).contains("h B"), "diamond callout");
|
||||
|
||||
assert!(appearance::build(&line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
|
||||
assert!(appearance::build(&line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
|
||||
assert!(appearance::build(&bare_doc(), &line_dict([f64::NAN, 0.0, 10.0, 0.0], "OpenArrow")).is_none(), "a non-finite endpoint is rejected");
|
||||
assert!(appearance::build(&bare_doc(), &line_dict([0.0, 0.0, 10.0, 0.0], "Foo")).is_none(), "an unknown ending is not replaced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2185,7 +2191,7 @@ fn note_drawing(name: Option<&str>, color: [f64; 3]) -> Vec<u8> {
|
||||
if let Some(n) = name {
|
||||
d.set(b"Name".to_vec(), Object::name(n));
|
||||
}
|
||||
crate::appearance::build(&d).expect("a note is drawn").decoded().unwrap()
|
||||
crate::appearance::build(&bare_doc(), &d).expect("a note is drawn").decoded().unwrap()
|
||||
}
|
||||
|
||||
/// ISO 32000-2 §12.5.6.4, Table 175: a viewer shall provide icons for the seven standard names,
|
||||
@@ -2233,7 +2239,7 @@ fn a_star_note_keeps_its_star_when_recoloured() {
|
||||
fn content_of(src: &str) -> String {
|
||||
let doc = document_of(&[src.as_bytes()]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().expect("a dictionary");
|
||||
let stream = appearance::build(&d).expect("drawn");
|
||||
let stream = appearance::build(&bare_doc(), &d).expect("drawn");
|
||||
String::from_utf8_lossy(&stream.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
@@ -2285,7 +2291,7 @@ fn bad_dash_arrays_draw_solid() {
|
||||
for w in [f64::NAN, f64::INFINITY, -2.0] {
|
||||
let dash = Object::Array([f64::NAN, 2.0].map(Object::Real).to_vec());
|
||||
d.set(b"Border".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Real(w), dash]));
|
||||
let c = String::from_utf8_lossy(&appearance::build(&d).unwrap().decoded().unwrap()).into_owned();
|
||||
let c = String::from_utf8_lossy(&appearance::build(&bare_doc(), &d).unwrap().decoded().unwrap()).into_owned();
|
||||
assert!(c.contains("1 w") && !c.contains(" d\n") && !c.contains("NaN") && !c.contains("inf"), "{w}: {c}");
|
||||
}
|
||||
}
|
||||
@@ -2307,7 +2313,7 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
|
||||
let caret = |extra: &str| {
|
||||
let doc = document_of(&[format!("<< /Subtype /Caret /Rect [30 30 60 60] /C [0.9 0.1 0.1] {extra} >>").as_bytes()]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
|
||||
let s = appearance::build(&d).unwrap();
|
||||
let s = appearance::build(&bare_doc(), &d).unwrap();
|
||||
(s.decoded().unwrap(), s.dict.get(b"Resources").and_then(Object::as_dict).is_some_and(|r| r.contains(b"Font")))
|
||||
};
|
||||
let has_pilcrow = |c: &[u8]| c.windows(5).any(|w| w == b"(\xb6) T");
|
||||
@@ -2335,7 +2341,7 @@ fn a_caret_with_a_paragraph_symbol_draws_the_pilcrow() {
|
||||
}
|
||||
let doc = document_of(&[b"<< /Subtype /Caret /Rect [30 30 30 30] /Sy /P >>"]);
|
||||
let d = doc.get(ObjRef::new(1, 0)).as_dict().cloned().unwrap();
|
||||
assert!(appearance::build(&d).is_some());
|
||||
assert!(appearance::build(&bare_doc(), &d).is_some());
|
||||
}
|
||||
|
||||
// ── Line annotations: leader lines and captions (ISO 32000-2 §12.5.6.7, Table 178) ──────────
|
||||
@@ -2355,7 +2361,7 @@ fn table_178_line(l: [f64; 4], extra: &[(&str, Object)]) -> Dict {
|
||||
|
||||
/// The built appearance's content.
|
||||
fn line_drawing(d: &Dict) -> String {
|
||||
let s = crate::appearance::build(d).expect("the line is drawn");
|
||||
let s = crate::appearance::build(&bare_doc(), d).expect("the line is drawn");
|
||||
String::from_utf8_lossy(&s.decoded().unwrap()).into_owned()
|
||||
}
|
||||
|
||||
@@ -2508,7 +2514,7 @@ fn a_diagonal_lines_leaders_are_perpendicular_and_its_caption_turns_with_it() {
|
||||
fn a_caption_from_rich_text_and_one_the_font_cant_draw() {
|
||||
let rc = "<?xml version=\"1.0\"?><body xmlns=\"http://www.w3.org/1999/xhtml\"><p>Dim & <b>5</b>0 mm <x></p></body>";
|
||||
let d = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("RC", Object::String(PdfString::text(rc)))]);
|
||||
let s = crate::appearance::build(&d).unwrap().decoded().unwrap();
|
||||
let s = crate::appearance::build(&bare_doc(), &d).unwrap().decoded().unwrap();
|
||||
assert!(s.windows(17).any(|w| w == b"(Dim & 50 mm <x>)"), "{}", String::from_utf8_lossy(&s));
|
||||
let cjk = table_178_line([0.0, 0.0, 100.0, 0.0], &[("Cap", Object::Bool(true)), ("Contents", Object::String(PdfString::text("長さ")))]);
|
||||
assert_eq!(crate::appearance::undrawable_text(&cjk), Some(("the line's caption", '長')));
|
||||
@@ -3211,3 +3217,83 @@ fn indirect_typewriter_names_keep_outer_whitespace_in_summaries() {
|
||||
check(&doc);
|
||||
check(&reopen(&doc));
|
||||
}
|
||||
|
||||
const QUAD: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0];
|
||||
|
||||
fn reals(v: &[f64]) -> Vec<Object> {
|
||||
v.iter().map(|x| Object::Real(*x)).collect()
|
||||
}
|
||||
|
||||
/// The appearance a `subtype` with `/QuadPoints` `quads` draws, as text.
|
||||
fn quad_appearance(subtype: &str, quads: Vec<Object>) -> Option<String> {
|
||||
quad_appearance_with(&bare_doc(), subtype, Object::Array(quads))
|
||||
}
|
||||
|
||||
fn quad_appearance_with(doc: &Document, subtype: &str, quad_points: Object) -> Option<String> {
|
||||
let mut d = Dict::new();
|
||||
d.set(b"Subtype".to_vec(), Object::name(subtype));
|
||||
d.set(b"Rect".to_vec(), Object::Array([20, 95, 40, 110].map(Object::Int).to_vec()));
|
||||
d.set(b"C".to_vec(), Object::Array(vec![Object::Int(1), Object::Int(0), Object::Int(0)]));
|
||||
d.set(b"QuadPoints".to_vec(), quad_points);
|
||||
appearance::build(doc, &d).map(|s| String::from_utf8_lossy(&s.decoded().unwrap()).into_owned())
|
||||
}
|
||||
|
||||
/// Appearance drawing reads `/QuadPoints` by the rule the other readers do, indirect objects
|
||||
/// included: entries by reference, and the array itself by reference, draw the same quads as
|
||||
/// the direct values.
|
||||
#[test]
|
||||
fn appearances_draw_indirect_quads_like_direct_ones() {
|
||||
for subtype in ["Redact", "Highlight"] {
|
||||
let clean = quad_appearance(subtype, reals(&QUAD)).unwrap();
|
||||
let mut doc = bare_doc();
|
||||
let by_ref: Vec<Object> = QUAD.iter().map(|v| Object::Ref(doc.add(Object::Real(*v)))).collect();
|
||||
assert_eq!(quad_appearance_with(&doc, subtype, Object::Array(by_ref)).as_deref(), Some(clean.as_str()), "{subtype}: entries by reference");
|
||||
let array_ref = Object::Ref(doc.add(Object::Array(reals(&QUAD))));
|
||||
assert_eq!(quad_appearance_with(&doc, subtype, array_ref).as_deref(), Some(clean.as_str()), "{subtype}: the array by reference");
|
||||
}
|
||||
}
|
||||
|
||||
/// Redaction outlines and text markup draw the quads redaction apply would use, no more: a NaN
|
||||
/// quad wrote `NaN` into the stream, and one non-number or a trailing partial quad left the other,
|
||||
/// usable quads undrawn.
|
||||
#[test]
|
||||
fn appearances_draw_only_usable_quads() {
|
||||
let mut one_nan = QUAD;
|
||||
one_nan[3] = f64::NAN;
|
||||
for subtype in ["Redact", "Highlight"] {
|
||||
let clean = quad_appearance(subtype, reals(&QUAD)).unwrap();
|
||||
assert!(!clean.contains("NaN"), "{clean}");
|
||||
let mut name_first = reals(&[QUAD, QUAD].concat());
|
||||
name_first[2] = Object::name("oops");
|
||||
let same =
|
||||
[reals(&[one_nan, QUAD].concat()), name_first, reals(&[&QUAD[..], &QUAD[..4]].concat()), reals(&[QUAD, [f64::INFINITY; 8]].concat())];
|
||||
for (i, quads) in same.into_iter().enumerate() {
|
||||
assert_eq!(quad_appearance(subtype, quads).as_deref(), Some(clean.as_str()), "{subtype} case {i}");
|
||||
}
|
||||
// No usable quad draws nothing, as with no /QuadPoints.
|
||||
assert_eq!(quad_appearance(subtype, reals(&[f64::NAN; 8])), None, "{subtype}");
|
||||
}
|
||||
}
|
||||
|
||||
/// The comment list reads `/QuadPoints` by the rule redaction apply does: unusable quads are left
|
||||
/// out, not read as zeros, and an indirect number is followed.
|
||||
#[test]
|
||||
fn summaries_list_only_usable_quads() {
|
||||
let mut doc = fixture();
|
||||
let forty = Object::Ref(doc.add(Object::Int(40)));
|
||||
let page = page_refs(&doc).unwrap()[0];
|
||||
let mut entries = annots(&doc, page);
|
||||
let Object::Dict(annotation) = &mut entries[0] else { panic!("fixture annotation is inline") };
|
||||
// A non-number in the first quad, an indirect 40 in the second, NaN in the third, and four
|
||||
// entries of a fourth.
|
||||
let mut quads = reals(&[QUAD, QUAD, QUAD, QUAD].concat());
|
||||
quads[1] = Object::name("oops");
|
||||
quads[10] = forty;
|
||||
quads[17] = Object::Real(f64::NAN);
|
||||
quads.truncate(28);
|
||||
annotation.set(b"Subtype".to_vec(), Object::name("Redact"));
|
||||
annotation.set(b"QuadPoints".to_vec(), Object::Array(quads));
|
||||
set_annots(&mut doc, page, entries).unwrap();
|
||||
let summary = summaries(&doc).into_iter().find(|a| a.page == 0 && a.index == 0).unwrap();
|
||||
assert_eq!(summary.quads, vec![QUAD.map(|v| v as f32)]);
|
||||
}
|
||||
|
||||
@@ -209,6 +209,30 @@ undoable and survive saving/reopening. `image_hit` shares the UI's curve and hol
|
||||
The bounds, limits and font requirements are in [existing-text editing](../../docs/editing-existing-text.md)
|
||||
and [image editing](../../docs/editing-images.md).
|
||||
|
||||
`text_fonts` also lists `builtin:thai` when the build includes the bundled Noto Sans Thai face
|
||||
(the optional craft-fonts input). Passing it as `text_edit.font_resource` explicitly replaces a
|
||||
paragraph or range with that face; the source font is never swapped for it silently. A build
|
||||
without the face never lists it, and `text_edit` refuses it there. A page whose own fonts fill
|
||||
the listing's 32 MiB preview budget can leave it out too; `text_edit` still accepts it then.
|
||||
|
||||
### Preparing grouped text
|
||||
|
||||
`text_prepare {doc, page}` makes supported grouped Form text editable on one page, as one click
|
||||
on grouped text in the GUI's Edit text does. It keeps the group's original glyph operators and shared source
|
||||
objects, records preparation and map repairs in the repair log, and is one undoable step. List
|
||||
`text_paragraphs` afterwards; its indices belong to the prepared page. Besides the document
|
||||
summary it returns what this edit did:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `prepared_groups` | integer | Form placements drawn in place, so their text is now the page's own. |
|
||||
| `repaired_fonts` | integer | Page fonts whose damaged legacy THSarabunPSK Thai map was repaired on a page-owned copy. |
|
||||
| `refused_groups` | array of strings | One `name: reason` per group left as it was: unsafe compositing, tags, malformed content, or text grouped more than eight levels deep. |
|
||||
|
||||
The page's own fonts with that damaged map are repaired too, whether their text was grouped or
|
||||
not. It fails, changing nothing, when the page has nothing to prepare or repair (the error gives
|
||||
the first reason a group was left as it was) or the page is out of range.
|
||||
|
||||
### Superscript and subscript normalization
|
||||
|
||||
`text_script` changes characters to their Unicode superscript or subscript forms; it does not
|
||||
|
||||
@@ -481,6 +481,10 @@ impl Automation {
|
||||
self.apply(&a, Edit::EditPageImage { page, index, change })?
|
||||
}
|
||||
}
|
||||
"text_prepare" => {
|
||||
let page = self.page(&a)?;
|
||||
self.prepare_text(&a, page)?
|
||||
}
|
||||
"text_paragraphs" => {
|
||||
let page = self.page(&a)?;
|
||||
let doc = self.doc_to_copy(&a)?;
|
||||
|
||||
@@ -502,6 +502,23 @@ impl Automation {
|
||||
pub(crate) fn apply(&mut self, a: &Args, edit: Edit) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
self.session.apply(id, edit).map_err(failed)?;
|
||||
self.applied(a)
|
||||
}
|
||||
|
||||
/// `text_prepare`: make a page's grouped text editable, with what that edit itself prepared,
|
||||
/// repaired and left grouped.
|
||||
pub(crate) fn prepare_text(&mut self, a: &Args, page: usize) -> Result<Value> {
|
||||
let id = self.doc(a)?.id;
|
||||
let prepared = self.session.prepare_text(id, page).map_err(failed)?;
|
||||
let mut out = self.applied(a)?;
|
||||
out["prepared_groups"] = json!(prepared.expanded);
|
||||
out["repaired_fonts"] = json!(prepared.repaired_fonts);
|
||||
out["refused_groups"] = json!(prepared.refused);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The document's summary after an edit, with what it stored as typed but drew with `?`.
|
||||
fn applied(&mut self, a: &Args) -> Result<Value> {
|
||||
let doc = self.doc(a)?;
|
||||
let mut out = summary(doc);
|
||||
// Text the edit stored as typed but drew with `?` (its font can't draw it): said, not silent.
|
||||
|
||||
@@ -1327,7 +1327,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
json!({ "doc": doc(), "field": { "type": "string" }, "event": { "type": "string", "enum": ["keystroke", "format", "validate", "calculate", "mouse_up"] }, "script": { "type": "string" } }),
|
||||
&["doc", "field", "event"],
|
||||
)),
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses borderless inferred tables and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
t("doc_export_office", "Export to Word, PowerPoint, HTML, RTF or Markdown", "Export a PDF ▸ Word (.docx), PowerPoint (.pptx, one slide per page), HTML (.html, one file with images inline), RTF (.rtf) or Markdown (.md or .markdown: pipe tables, images left out), chosen by path's extension: text, headings, bold and italic. HTML uses rich styling by default; set html_mode to simple for CSS-free headings, paragraphs, emphasis and tables. Simple HTML keeps text and inline images but omits colour and layout styling. Word uses inferred tables, bordered only along the grid lines the page draws, and positioned editable shapes on pages with artwork, rotated or large text; each source page retains its size. Font matching and vector graphics remain limited. Export reads forms 50 deep and up to 50 000 a page, counting forms the page draws itself. Pages that go past either limit, or that have pictures past what one export may hold or decode, are listed in truncated_pages, with a warning; what was left out is missing.")
|
||||
.cmd("export.docx")
|
||||
.with(schema(json!({ "doc": doc(), "path": { "type": "string" }, "html_mode": { "type": "string", "enum": ["rich", "simple"], "default": "rich" } }), &["doc", "path"])),
|
||||
t("pdfa_verify", "Verify PDF/A", "Standards ▸ Verify PDF/A compliance: the PDF/A-2b or 3b rules the document breaks (ISO 19005 clause, message, page, whether Save as PDF/A can fix it), plus what it declares.")
|
||||
@@ -1588,9 +1588,12 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
t("text_font_import", "Load document font", "Load an explicitly selected, openly licensed full TrueType/OpenType face into a page. The program's family, embedding permissions and licence declaration are validated. Completes source subsets of the same face without replacing existing glyphs. Returns a font resource usable with text_edit.font_resource. Undoable; respects root confinement. No system-interface fallback is exported.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}, "path":{"type":"string"}}), &["doc","page","path"])),
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources can be selected by text_edit.font_resource; preview_only faces are not PDF resource names.")
|
||||
t("text_fonts", "List document fonts", "List validated embedded source faces and approved full preview faces for a page. Full registered resources and bundled choices can be selected by text_edit.font_resource. builtin:thai explicitly replaces a paragraph or range with the bundled Noto Sans Thai; a build without that face never lists it and text_edit refuses it there (a page whose fonts fill the listing budget may also omit it, but text_edit still accepts it). preview_only faces cannot be selected.")
|
||||
.ro().cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_prepare", "Prepare grouped text", "Make existing text inside supported Form XObjects directly editable on one page. Replaces placements with their original operators and resources, preserving position, clipping and other pages. Refuses unsafe transparency, tags or malformed content. Adds no text or cover boxes. Returns prepared_groups (placements made editable), repaired_fonts (damaged legacy Thai maps repaired) and refused_groups ('name: reason' for each group left as it was). Also repairs the page's own damaged legacy Thai maps, grouped or not. Fails when the page has nothing to prepare or repair. Undoable; list text_paragraphs again afterwards.")
|
||||
.cmd("edit.edit_text")
|
||||
.with(schema(json!({"doc":doc(), "page":{"type":"integer","minimum":1}}), &["doc","page"])),
|
||||
t("text_paragraphs", "List paragraphs", "The paragraphs on a page (adjacent source runs grouped into aligned visual rows, including mixed fonts, sizes and colours): number, text, editor_text (visual rows separated by newlines), rows and source line numbers, box (top-left-origin points), font and size. Use the number with text_edit's paragraph: until you list the page again, a number names the paragraph listed here, even after edits regroup the page.")
|
||||
.ro()
|
||||
.cmd("edit.edit_text")
|
||||
@@ -1604,7 +1607,7 @@ pub fn tools() -> Vec<ToolDef> {
|
||||
.with(schema(
|
||||
json!({ "doc": doc(), "page": { "type": "integer", "minimum": 1 }, "line": { "type": "integer", "minimum": 1 },
|
||||
"paragraph": { "type": "integer", "minimum": 1 },
|
||||
"font_resource": { "type": "string", "description": "Explicit source/full page font resource from text_fonts or text_font_import." },
|
||||
"font_resource": { "type": "string", "description": "Paragraphs: source/full page resource or bundled document-font choice from text_fonts (builtin:thai selects the bundled Thai face); also accepts text_font_import resources." },
|
||||
"ranges": { "type": "array", "maxItems": 1024, "items": { "type": "object", "properties": {"range":{"type":"array","items":{"type":"integer","minimum":0},"minItems":2,"maxItems":2},"font":{"type":"string","enum":["helvetica","times","courier"]},"bold":{"type":"boolean"},"italic":{"type":"boolean"},"font_resource":{"type":"string"},"size":{"type":"number","exclusiveMinimum":0},"color":{"type":"string"},"underline":{"type":"boolean"},"char_spacing":{"type":"number"},"scale":{"type":"number"}},"required":["range"]}, "description":"Disjoint UTF-8 byte ranges in replacement text; unspecified source formatting survives." },
|
||||
"text": { "type": "string" },
|
||||
"font": { "type": "string", "enum": ["helvetica", "times", "courier"] },
|
||||
|
||||
@@ -11908,3 +11908,151 @@ fn comments_copy_and_paste_between_documents_through_tools() {
|
||||
assert!(matches!(a.call("comments_copy", &json!({ "doc": src, "comments": [[2]] })), Err(ToolError::InvalidArgs(_))));
|
||||
assert_eq!(ok(&mut a, "comment_list", json!({ "doc": src }))["comments"].as_array().unwrap().len(), 2, "the source is untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_bundled_thai_font_choice_layout_edit_undo_and_reopen() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = page_text(&mut a, doc);
|
||||
let fonts = ok(&mut a, "text_fonts", json!({"doc":doc,"page":1}));
|
||||
let choice = fonts["fonts"].as_array().unwrap().iter().find(|f| f["resource"] == "builtin:thai");
|
||||
let args = json!({"doc":doc,"page":1,"paragraph":1,"text":"น้ำ กิ่ กี้ ผู้ใช้","font_resource":"builtin:thai"});
|
||||
if pdfcraft_fonts::document_thai_font().is_none() {
|
||||
assert!(choice.is_none());
|
||||
assert!(a.call("text_edit", &args).is_err());
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
return;
|
||||
}
|
||||
assert_eq!(choice.unwrap()["preview_only"], false);
|
||||
ok(&mut a, "text_edit_layout", args.clone());
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
ok(&mut a, "text_edit", args);
|
||||
let edited = page_text(&mut a, doc);
|
||||
assert!(edited[0].contains("น้ำ กิ่ กี้ ผู้ใช้"));
|
||||
ok(&mut a, "edit_undo", json!({"doc":doc}));
|
||||
assert_eq!(page_text(&mut a, doc), before);
|
||||
ok(&mut a, "edit_redo", json!({"doc":doc}));
|
||||
ok(&mut a, "doc_save", json!({"doc":doc,"path":"edited.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(page_text(&mut a, reopened), edited);
|
||||
let Err(ToolError::Failed(m)) =
|
||||
a.call("text_edit", &json!({"doc":reopened,"page":1,"paragraph":1,"text":"น้ำ 😀","font_resource":"builtin:thai"}))
|
||||
else {
|
||||
panic!("Noto Sans Thai has no emoji")
|
||||
};
|
||||
assert!(m.contains("Noto Sans Thai") && m.contains("U+1F600"), "the error names the font and the character: {m}");
|
||||
assert_eq!(page_text(&mut a, reopened), edited);
|
||||
let image = a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":144})).unwrap();
|
||||
assert!(image.iter().any(|c| matches!(c, Content::Png { .. })));
|
||||
}
|
||||
|
||||
/// `text_prepare` refuses what it can't act on, changing nothing: a page with no grouped text
|
||||
/// (with the reason), and a page the document doesn't have.
|
||||
#[test]
|
||||
fn text_prepare_refuses_pages_without_grouped_text() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
std::fs::write(dir.join("source.pdf"), fixture(1)).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let doc = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let Err(ToolError::Failed(m)) = a.call("text_prepare", &json!({"doc":doc,"page":1})) else { panic!("nothing to prepare") };
|
||||
assert!(m.contains("no supported grouped text"), "{m}");
|
||||
let Err(ToolError::InvalidArgs(m)) = a.call("text_prepare", &json!({"doc":doc,"page":2})) else { panic!("no page 2") };
|
||||
assert!(m.contains("out of range"), "{m}");
|
||||
assert!(a.call("edit_undo", &json!({"doc":doc})).is_err(), "nothing was recorded to undo");
|
||||
assert_eq!(ok(&mut a, "doc_info", json!({"doc":doc}))["document"]["dirty"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grouped_existing_thai_body_edits_saves_reopens_and_undo_restores_original_glyphs() {
|
||||
use pdfcraft_cos::{Dict, Document, ObjRef, Object, SaveOptions, Stream, write_full};
|
||||
use std::sync::Arc;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let dir = temp.path();
|
||||
let mut source = Document::open(Arc::new(fixture(2))).unwrap();
|
||||
let old = "ข้อความเดิม น้ำ ผู้ใช้";
|
||||
let new = "ข้อความแก้ไข น้ำ ผู้ใช้";
|
||||
let mut cmap = String::from("begincmap 1 begincodespacerange <00> <ff> endcodespacerange\n");
|
||||
cmap.push_str(&format!("{} beginbfchar\n", old.chars().count()));
|
||||
for (i, ch) in old.chars().enumerate() {
|
||||
cmap.push_str(&format!("<{:02x}> <{:04x}>\n", 65 + i, u32::from(ch)));
|
||||
}
|
||||
cmap.push_str("endbfchar endcmap");
|
||||
let unicode = source.add(Object::Stream(Stream::flate(Dict::new(), cmap.as_bytes())));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Type".to_vec(), Object::name("Font"));
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type1"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+LegacyThai"));
|
||||
font.set(b"FirstChar".to_vec(), Object::Int(65));
|
||||
font.set(b"LastChar".to_vec(), Object::Int(65 + old.chars().count() as i64 - 1));
|
||||
font.set(b"Widths".to_vec(), Object::Array(vec![Object::Int(300); old.chars().count()]));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(unicode));
|
||||
let font = source.add(Object::Dict(font));
|
||||
let mut fonts = Dict::new();
|
||||
fonts.set(b"F1".to_vec(), Object::Ref(font));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
let mut form = Dict::new();
|
||||
form.set(b"Type".to_vec(), Object::name("XObject"));
|
||||
form.set(b"Subtype".to_vec(), Object::name("Form"));
|
||||
form.set(b"BBox".to_vec(), Object::Array(vec![Object::Int(0), Object::Int(0), Object::Int(200), Object::Int(300)]));
|
||||
form.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
let codes: String = (0..old.chars().count()).map(|i| format!("{:02x}", 65 + i)).collect();
|
||||
let form = source.add(Object::Stream(Stream::flate(form, format!("BT /F1 14 Tf 20 220 Td <{codes}> Tj ET").as_bytes())));
|
||||
for page in [ObjRef::new(4, 0), ObjRef::new(6, 0)] {
|
||||
let mut xobjects = Dict::new();
|
||||
xobjects.set(b"Body".to_vec(), Object::Ref(form));
|
||||
let mut resources = Dict::new();
|
||||
resources.set(b"XObject".to_vec(), Object::Dict(xobjects));
|
||||
let content = source.add(Object::Stream(Stream::flate(Dict::new(), b"q /Body Do Q")));
|
||||
source
|
||||
.update_dict(page, |d| {
|
||||
d.set(b"Resources".to_vec(), Object::Dict(resources));
|
||||
d.set(b"Contents".to_vec(), Object::Ref(content));
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(dir.join("source.pdf"), write_full(&source, &SaveOptions::default()).unwrap()).unwrap();
|
||||
let mut a = auto(dir);
|
||||
let id = ok(&mut a, "doc_open", json!({"path":"source.pdf"}))["doc"].as_u64().unwrap();
|
||||
let before = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0);
|
||||
let prepared = ok(&mut a, "text_prepare", json!({"doc":id,"page":1}));
|
||||
assert_eq!(
|
||||
[&prepared["prepared_groups"], &prepared["repaired_fonts"], &prepared["refused_groups"]],
|
||||
[&json!(1), &json!(0), &json!([])],
|
||||
"{prepared}"
|
||||
);
|
||||
assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before, "preparing must not change pixels");
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["paragraphs"][0]["text"], old);
|
||||
// The bundled Thai face is an optional build input. Without it, `builtin:thai` is refused and
|
||||
// the grouped edit, undo, redo and save checks run on Latin text in Helvetica instead.
|
||||
let thai = json!({"doc":id,"page":1,"paragraph":1,"text":new,"font_resource":"builtin:thai"});
|
||||
let (new, args) = if pdfcraft_fonts::document_thai_font().is_some() {
|
||||
(new, thai)
|
||||
} else {
|
||||
let Err(ToolError::Failed(m)) = a.call("text_edit", &thai) else { panic!("builtin:thai needs the bundled Thai face") };
|
||||
assert!(m.contains("builtin:thai"), "{m}");
|
||||
let latin = "Edited body";
|
||||
(latin, json!({"doc":id,"page":1,"paragraph":1,"text":latin,"font":"helvetica"}))
|
||||
};
|
||||
ok(&mut a, "text_edit", args);
|
||||
assert_eq!(ok(&mut a, "text_lines", json!({"doc":id,"page":1}))["lines"][0]["text"].as_str().unwrap().trim(), new);
|
||||
assert_eq!(page_text(&mut a, id)[1], old, "a shared form's other page must not change");
|
||||
let after = a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap();
|
||||
assert_ne!(after, before);
|
||||
ok(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(a.call("page_render", &json!({"doc":id,"page":1,"dpi":72})).unwrap(), before);
|
||||
ok(&mut a, "edit_undo", json!({"doc":id}));
|
||||
assert_eq!(ok(&mut a, "text_paragraphs", json!({"doc":id,"page":1}))["count"], 0);
|
||||
ok(&mut a, "edit_redo", json!({"doc":id}));
|
||||
ok(&mut a, "edit_redo", json!({"doc":id}));
|
||||
ok(&mut a, "doc_save", json!({"doc":id,"path":"edited.pdf"}));
|
||||
let reopened = ok(&mut a, "doc_open", json!({"path":"edited.pdf"}))["doc"].as_u64().unwrap();
|
||||
assert_eq!(a.call("page_render", &json!({"doc":reopened,"page":1,"dpi":72})).unwrap(), after);
|
||||
assert_eq!(page_text(&mut a, reopened), [new, old]);
|
||||
assert_eq!(ok(&mut a, "content_list", json!({"doc":reopened}))["count"], 0, "editing existing text must not create Add Text objects");
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ mod layout;
|
||||
mod store;
|
||||
mod types;
|
||||
|
||||
pub(crate) use draw::thai_glyph_resources;
|
||||
#[cfg(test)]
|
||||
use draw::{GLYPHS_PER_FONT, LaidLine, draw_laid_shaped};
|
||||
#[cfg(test)]
|
||||
@@ -87,3 +88,5 @@ fn align_from(code: Option<i64>) -> Align {
|
||||
|
||||
#[cfg(test)]
|
||||
mod arabic_tests;
|
||||
#[cfg(test)]
|
||||
mod thai_tests;
|
||||
|
||||
@@ -390,3 +390,55 @@ pub(super) fn params(c: &Content) -> Dict {
|
||||
}
|
||||
d
|
||||
}
|
||||
|
||||
/// Existing Thai text uses absolute shaped glyph positions. Reuse Add Text's bounded outline
|
||||
/// writer instead of trying to embed the matching craft-fonts variable program as a static face.
|
||||
pub(crate) fn thai_glyph_resources(
|
||||
doc: &mut Document,
|
||||
fonts: &mut Dict,
|
||||
shaped: &pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
) -> Result<Vec<(String, Vec<u8>)>, EditError> {
|
||||
if shaped.glyphs.len() > 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
let mut outlines = HashMap::new();
|
||||
let mut clusters = Vec::new();
|
||||
let mut index = HashMap::new();
|
||||
let mut indices = Vec::new();
|
||||
for glyph in &shaped.glyphs {
|
||||
let id = glyph.id.to_u32();
|
||||
let key = (ShapedScript::Thai, id);
|
||||
if let std::collections::hash_map::Entry::Vacant(entry) = outlines.entry(key) {
|
||||
entry.insert(shaped_glyph(ShapedScript::Thai, id).map_err(|e| thai_error(e, &glyph.text))?);
|
||||
}
|
||||
let at = *index.entry((id, glyph.text.as_str())).or_insert_with(|| {
|
||||
let at = clusters.len();
|
||||
let width = outlines.get(&key).map_or(0.0, |g| g.width);
|
||||
clusters.push(ShapedCluster { script: ShapedScript::Thai, glyphs: vec![(id, [0.0, 0.0])], advance: width, text: glyph.text.clone() });
|
||||
at
|
||||
});
|
||||
indices.push(at);
|
||||
}
|
||||
// Validate all outlines before adding resources. Type 3 character codes are single bytes;
|
||||
// shard long rows and return the appropriate resource/code for every positioned glyph.
|
||||
let mut names = Vec::new();
|
||||
for chunk in clusters.chunks(GLYPHS_PER_FONT) {
|
||||
let refs: Vec<_> = chunk.iter().collect();
|
||||
let font = type3_shaped(doc, &refs, &outlines);
|
||||
doc.update_dict(font, |f| f.set(b"Name".to_vec(), Object::name("PCThai")))?;
|
||||
let mut name = format!("PCThai{}", font.num);
|
||||
while fonts.contains(name.as_bytes()) {
|
||||
name.push('_');
|
||||
}
|
||||
fonts.set(name.as_bytes().to_vec(), Object::Ref(font));
|
||||
names.push(name);
|
||||
}
|
||||
indices
|
||||
.into_iter()
|
||||
.map(|at| {
|
||||
let name = names.get(at / GLYPHS_PER_FONT).ok_or_else(|| EditError::Invalid("Thai font resource is missing".into()))?;
|
||||
let code = u8::try_from(at % GLYPHS_PER_FONT + 1).map_err(|_| EditError::Invalid("Thai glyph limit".into()))?;
|
||||
Ok((name.clone(), vec![code]))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Existing Thai text drawn from the bundled Thai outlines (`thai_glyph_resources`): one Type 3
|
||||
//! code per distinct glyph and cluster text, in fonts of `GLYPHS_PER_FONT` codes each. The
|
||||
//! outline checks need the craft-fonts Thai face; the budget check runs without it.
|
||||
|
||||
use pdfcraft_cos::{Dict, Document};
|
||||
use pdfcraft_fonts::source_shaping::{ShapedGlyph, ShapedRun};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `count` copies of `glyph`, each with cluster text of its own (so each needs a code of its own).
|
||||
fn distinct(glyph: &ShapedGlyph, count: usize) -> ShapedRun {
|
||||
let glyphs = (0..count).map(|i| ShapedGlyph { position: [i as f64, 0.0], text: format!("{i}"), ..glyph.clone() }).collect();
|
||||
ShapedRun { glyphs, advance: count as f64 }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_glyph_resources_refuse_a_row_past_the_glyph_budget() {
|
||||
let glyph = ShapedGlyph { id: Default::default(), position: [0.0, 0.0], text: String::new() };
|
||||
let over = ShapedRun { glyphs: vec![glyph; 65_535], advance: 0.0 };
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
assert!(thai_glyph_resources(&mut doc, &mut fonts, &over).is_err());
|
||||
assert!(fonts.is_empty(), "a refused row adds no font");
|
||||
}
|
||||
|
||||
/// Codes fill a font up to `GLYPHS_PER_FONT`, the next distinct cluster starts another font at
|
||||
/// code 1, and the same glyph with the same text again reuses its code. One glyph drawn for two
|
||||
/// cluster texts takes two codes, since each code's ToUnicode gives its own text.
|
||||
#[test]
|
||||
fn thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary() {
|
||||
let Some(face) = pdfcraft_fonts::document_thai_font() else {
|
||||
eprintln!(
|
||||
"skipping thai_glyph_resources_shard_distinct_clusters_at_the_font_boundary: built without craft-fonts (set CRAFT_FONTS_DIR to run it)"
|
||||
);
|
||||
return;
|
||||
};
|
||||
let ko = pdfcraft_fonts::source_shaping::shape(face.bytes, "ก").unwrap().glyphs.remove(0);
|
||||
for (clusters, fonts_used) in [(GLYPHS_PER_FONT - 1, 1), (GLYPHS_PER_FONT, 1), (GLYPHS_PER_FONT + 1, 2)] {
|
||||
let mut shaped = distinct(&ko, clusters);
|
||||
shaped.glyphs.push(shaped.glyphs[0].clone());
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
let placed = thai_glyph_resources(&mut doc, &mut fonts, &shaped).unwrap();
|
||||
assert_eq!((fonts.len(), placed.len()), (fonts_used, clusters + 1), "{clusters} clusters");
|
||||
assert_eq!(placed.last(), placed.first(), "a repeated glyph and text reuses its code");
|
||||
let codes: std::collections::HashSet<_> = placed.iter().collect();
|
||||
assert_eq!(codes.len(), clusters, "every distinct cluster has a code of its own");
|
||||
assert_eq!(placed[GLYPHS_PER_FONT - 2].1, [(GLYPHS_PER_FONT - 1) as u8]);
|
||||
if clusters > GLYPHS_PER_FONT {
|
||||
assert_eq!(placed[GLYPHS_PER_FONT - 1].1, [GLYPHS_PER_FONT as u8]);
|
||||
assert_eq!(placed[GLYPHS_PER_FONT].1, [1], "the next font starts at code 1");
|
||||
assert_ne!(placed[GLYPHS_PER_FONT].0, placed[0].0);
|
||||
}
|
||||
}
|
||||
// At the budget: one cluster repeated 65 534 times needs one code.
|
||||
let mut row = distinct(&ko, 1);
|
||||
row.glyphs = vec![row.glyphs[0].clone(); 65_534];
|
||||
let (mut doc, mut fonts) = (Document::new_empty(), Dict::new());
|
||||
let placed = thai_glyph_resources(&mut doc, &mut fonts, &row).unwrap();
|
||||
assert_eq!((fonts.len(), placed.len()), (1, 65_534));
|
||||
}
|
||||
@@ -49,7 +49,7 @@ pub struct PageImage {
|
||||
/// The content stream its `Do` (its first token) is in, and the `Do`'s index among the
|
||||
/// operators of the page's streams joined ([`pdfcraft_content::Pieces`]).
|
||||
pub stream: usize,
|
||||
op: usize,
|
||||
pub(crate) op: usize,
|
||||
/// CTM before `Do`, excluding a Form's own Matrix.
|
||||
draw_matrix: [f64; 6],
|
||||
clips: Vec<ClipPath>,
|
||||
@@ -1668,10 +1668,11 @@ pub fn change_image(doc: &mut Document, page: usize, index: usize, change: &Imag
|
||||
return Err(EditError::Invalid("only grouped Form artwork can be ungrouped".into()));
|
||||
}
|
||||
let res = p.dict.get(b"Resources").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let translucent = crate::ungroup::translucent_at(doc, &res, &ops, img.op);
|
||||
let space = crate::ungroup::page_blend_space(doc, &p.dict);
|
||||
let (inline, mut merged, patterns) =
|
||||
crate::ungroup::ungroup_ops(doc, &res, img.name.as_bytes(), translucent, space.as_ref(), Matrix(img.matrix))?;
|
||||
// One allowance for the form and everything it draws.
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(crate::ungroup::MAX_FORM_BYTES, 0);
|
||||
let crate::ungroup::Ungrouped { ops: inline, resources: mut merged, patterns, .. } =
|
||||
crate::ungroup::ungroup_ops(doc, &res, &ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget).map_err(|r| r.error)?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, copy) in patterns {
|
||||
|
||||
+1128
-1
File diff suppressed because it is too large
Load Diff
@@ -31,6 +31,7 @@ pub use session::TextSession;
|
||||
mod blocks;
|
||||
mod editor;
|
||||
mod fallback;
|
||||
pub mod forms;
|
||||
mod interp;
|
||||
mod lines;
|
||||
mod rewrite;
|
||||
@@ -42,8 +43,8 @@ pub use blocks::{
|
||||
BlockKey, BlockStyle, RangeStyle, ReadingBlocks, TextBlock, block_overlapping, clear_block, reading_blocks, reading_blocks_checked,
|
||||
replace_block, text_blocks,
|
||||
};
|
||||
use editor::layout_draft;
|
||||
pub use editor::{TextEditLayout, TextEditorFont, register_text_font, text_edit_layout, text_editor_fonts};
|
||||
use editor::{builtin_thai_choice, layout_draft};
|
||||
#[cfg(test)]
|
||||
pub(crate) use fallback::SOURCE_SPACE_CALLS;
|
||||
use fallback::{
|
||||
|
||||
@@ -37,6 +37,13 @@ pub struct TextBlock {
|
||||
}
|
||||
|
||||
impl TextBlock {
|
||||
/// The one size a rewrite and the editor set an OCR phrase in, in place of each word's own
|
||||
/// (#3313): the block's size, which for invisible text is its words' median. `None` for
|
||||
/// visible text, whose runs keep their sizes.
|
||||
pub fn phrase_size(&self) -> Option<f64> {
|
||||
(self.invisible && self.size.is_finite() && self.size > 0.0).then_some(self.size)
|
||||
}
|
||||
|
||||
/// Preserve styles outside the changed ranges; inserted characters inherit the surrounding
|
||||
/// source run. The texts are aligned word by word, so words left untouched between two edits
|
||||
/// keep their own runs (#2198); past [`MAX_CHANGED_WORDS`] the draft counts as one change.
|
||||
@@ -400,7 +407,7 @@ pub fn replace_block(doc: &mut Document, page: usize, block: usize, text: &str)
|
||||
pub struct BlockStyle {
|
||||
/// Disjoint UTF-8 ranges in the replacement, each preserving unspecified source style.
|
||||
pub ranges: Vec<RangeStyle>,
|
||||
/// Explicit page font resource (from register_text_font or the page font inventory).
|
||||
/// Explicit page font resource or bundled document-font choice from the page font inventory.
|
||||
pub resource: Option<String>,
|
||||
/// A standard font family, bold, italic.
|
||||
pub family: Option<(crate::added::Family, bool, bool)>,
|
||||
|
||||
@@ -2,15 +2,30 @@ use super::blocks::{BlockStyle, TextBlock};
|
||||
use super::fallback::MAX_TYPE3_GLYPHS;
|
||||
use super::lines::{page_dict, text_lines};
|
||||
use super::rewrite::rewrite_block_from;
|
||||
use super::{DecodeBudget, Document, EditError, Metrics, Object, TextLine, TextSession};
|
||||
use super::{DecodeBudget, Dict, Document, EditError, Metrics, Object, TextLine, TextSession};
|
||||
|
||||
/// An embedded sfnt source face, read only for the inline editor. Bytes stay in memory and
|
||||
/// are never installed, copied into assets, or used as a font for another document.
|
||||
/// An embedded source font or an approved bundled document-font choice.
|
||||
/// Source bytes stay in memory and are never installed or copied into another document.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct TextEditorFont {
|
||||
pub resource: String,
|
||||
pub face: String,
|
||||
pub bytes: Vec<u8>,
|
||||
/// A selectable replacement, rather than a source/matching preview face. Merely listing
|
||||
/// this choice must not reload an open editor's font atlas.
|
||||
pub choice_only: bool,
|
||||
}
|
||||
|
||||
/// Reserved document-font choice for explicitly replacing text with the bundled Thai face.
|
||||
/// This is a selector, never a font resource written into a PDF.
|
||||
const BUILTIN_THAI_FONT: &str = "builtin:thai";
|
||||
|
||||
pub(super) fn builtin_thai_choice(fonts: &Dict) -> String {
|
||||
let mut choice = BUILTIN_THAI_FONT.to_owned();
|
||||
while fonts.contains(choice.as_bytes()) {
|
||||
choice.push('_');
|
||||
}
|
||||
choice
|
||||
}
|
||||
|
||||
pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFont>, EditError> {
|
||||
@@ -32,6 +47,7 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
|
||||
resource: String::from_utf8_lossy(name).into_owned(),
|
||||
face: Metrics::from_dict(doc, &font).base_font.rsplit('+').next().unwrap_or_default().to_owned(),
|
||||
bytes,
|
||||
choice_only: false,
|
||||
});
|
||||
}
|
||||
// The approved full face follows all source subsets in the editor's font stack. It can
|
||||
@@ -43,9 +59,26 @@ pub fn text_editor_fonts(doc: &Document, page: usize) -> Result<Vec<TextEditorFo
|
||||
continue;
|
||||
}
|
||||
budget -= full.bytes.len();
|
||||
result.push(TextEditorFont { resource: format!("PCApproved{}", full.name()), face: face.clone(), bytes: full.bytes.to_vec() });
|
||||
result.push(TextEditorFont {
|
||||
resource: format!("PCApproved{}", full.name()),
|
||||
face: face.clone(),
|
||||
bytes: full.bytes.to_vec(),
|
||||
choice_only: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// A missing source family must not make the bundled Thai replacement undiscoverable.
|
||||
// Offering it does not change the source; callers explicitly select this choice to replace it.
|
||||
if let Some(thai) = pdfcraft_fonts::document_thai_font()
|
||||
&& thai.bytes.len() <= budget
|
||||
{
|
||||
result.push(TextEditorFont {
|
||||
resource: builtin_thai_choice(&fonts),
|
||||
face: thai.family.into(),
|
||||
bytes: thai.bytes.to_vec(),
|
||||
choice_only: true,
|
||||
});
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,13 +24,13 @@ const HEBREW_FONT_NAME: &str = "PCHebrew";
|
||||
/// The `/Name` of the Type 3 fonts generated from the bundled Latin (Inter) face.
|
||||
const LATIN_FONT_NAME: &str = "PCLatin";
|
||||
|
||||
/// Whether `font` is one of those generated fonts (Latin, Japanese or Hebrew): a replacement longer
|
||||
/// Whether `font` is one of those generated fonts (Latin, Japanese, Hebrew or Thai): a replacement longer
|
||||
/// than one of them is split across several, which read back as one line (see [`same_font`]).
|
||||
pub(super) fn is_type3_fallback(font: &Dict) -> bool {
|
||||
font.name(b"Subtype") == Some(b"Type3")
|
||||
&& font
|
||||
.name(b"Name")
|
||||
.is_some_and(|n| n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes())
|
||||
&& font.name(b"Name").is_some_and(|n| {
|
||||
n == JAPANESE_FONT_NAME.as_bytes() || n == HEBREW_FONT_NAME.as_bytes() || n == LATIN_FONT_NAME.as_bytes() || n == b"PCThai"
|
||||
})
|
||||
}
|
||||
|
||||
/// The generated font's `/Name` and its resource prefix: the bundled Latin face, Hebrew, or the
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
//! Make text in ordinary Form XObjects available to the existing paragraph editor.
|
||||
//!
|
||||
//! This replaces each supported placement's `Do`, never the shared form object. The same
|
||||
//! source glyph operators become page content; no cover rectangle or replacement text box is
|
||||
//! added. Unsupported compositing and malformed streams stay intact.
|
||||
|
||||
use pdfcraft_content::Matrix;
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
|
||||
use super::{ContentStream, Read, content_streams, joined, page_dict, rewritten_contents, splice};
|
||||
use crate::{EditError, ungroup};
|
||||
|
||||
/// Result of preparing one page. Refused groups remain in the page, with an actionable reason.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Preparation {
|
||||
pub expanded: usize,
|
||||
pub refused: Vec<String>,
|
||||
pub repaired_fonts: usize,
|
||||
}
|
||||
|
||||
/// How many levels of nested groups preparation opens on one page. Text nested deeper stays
|
||||
/// grouped and is reported in [`Preparation::refused`].
|
||||
const MAX_LEVELS: usize = 8;
|
||||
|
||||
/// What preparing one page may decode, and add to its content, in all.
|
||||
const MAX_PREPARED_BYTES: usize = 16 << 20;
|
||||
|
||||
/// What one pass of [`prepare`] inlined, as positions in the next pass's parse of the page.
|
||||
struct Inlined {
|
||||
/// How many operators that parse has; with any other count the positions are wrong.
|
||||
ops: usize,
|
||||
/// Where each inlined form's operators are: the placements in them are new.
|
||||
ranges: Vec<std::ops::Range<usize>>,
|
||||
/// Earlier placements refused only because their resources needed renaming
|
||||
/// ([`crate::ungroup::Refusal::renaming`]), each with the reason it was given.
|
||||
waiting: Vec<(usize, String)>,
|
||||
}
|
||||
|
||||
/// Prepare grouped source text atomically, within eight levels and 64 placements per page, and
|
||||
/// repair the page fonts' damaged legacy Thai maps (`repair_thai_fonts`), grouped or not.
|
||||
/// Shared forms and other pages are untouched. Preparation is a distinct undoable engine edit.
|
||||
pub fn prepare(doc: &mut Document, page: usize) -> Result<Preparation, EditError> {
|
||||
let mut next = doc.clone();
|
||||
let mut result = Preparation::default();
|
||||
let mut bytes_left = MAX_PREPARED_BYTES;
|
||||
let mut visits = 0usize;
|
||||
let mut decode = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0);
|
||||
// Forms found to be artwork, or refused for a reason in the form itself (`Refusal::intrinsic`):
|
||||
// a placement of one that an expanded group adds is left as it is without being read or
|
||||
// counted, so logos drawn inside many text groups don't use up the limit. A form refused only
|
||||
// where it is drawn is read again where an expanded group draws it.
|
||||
let mut settled = std::collections::HashSet::new();
|
||||
// What the previous pass inlined. Only the placements inside it are new. Every other placement
|
||||
// was read and counted on an earlier pass, so a group refused where it is drawn counts once.
|
||||
// Reading one again gives the same answer (an inlined form restores the graphics state it
|
||||
// changes, and the decode budget only shrinks), except for a group refused because its
|
||||
// resources needed renaming: names merged in for other groups since can make that
|
||||
// unnecessary. Such a group is read again, uncounted, once its resource names say so.
|
||||
let mut inlined: Option<Inlined> = None;
|
||||
// One pass opens one level of every placement; the last pass only reports what is deeper.
|
||||
for pass in 0..=MAX_LEVELS {
|
||||
let p = page_dict(&next, page)?;
|
||||
let streams = strict_streams(&next, &p.dict)?;
|
||||
let pieces = joined(&streams);
|
||||
let parsed = pieces.parse();
|
||||
if parsed.skipped > 0 {
|
||||
return Err(EditError::Invalid("the page has unreadable content; grouped text was left unchanged".into()));
|
||||
}
|
||||
let ops = parsed.ops;
|
||||
// `None` reads every placement: on the first pass, and if the page didn't parse back to
|
||||
// the operators written, where the positions can't be trusted. Reading one again can only
|
||||
// count it twice; skipping one could leave its text grouped without a reason.
|
||||
let (new, mut waiting) = match inlined.take() {
|
||||
Some(i) if i.ops == ops.len() => (Some(i.ranges), i.waiting),
|
||||
_ => (None, Vec::new()),
|
||||
};
|
||||
let mut still_waiting = Vec::new();
|
||||
let resources = p.dict.get(b"Resources").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let space = ungroup::page_blend_space(&next, &p.dict);
|
||||
let images: std::collections::BTreeMap<_, _> =
|
||||
crate::page_images(&next, page)?.into_iter().filter(|i| i.is_form).map(|i| (i.op, i)).collect();
|
||||
let mut replacements = std::collections::BTreeMap::new();
|
||||
let mut merged = resources;
|
||||
let (mut refused_here, mut expanded_here) = (std::collections::HashSet::new(), std::collections::HashSet::new());
|
||||
for (at, op) in ops.iter().enumerate() {
|
||||
let Some(image) = images.get(&at) else { continue };
|
||||
if image.object.is_some_and(|o| settled.contains(&o)) {
|
||||
continue;
|
||||
}
|
||||
// An earlier placement is read again only when it was refused for a rename that the
|
||||
// names merged since have made unnecessary. The last pass opens nothing, so there it
|
||||
// keeps its reason.
|
||||
let earlier = new.as_ref().is_some_and(|ranges| !ranges.iter().any(|r| r.contains(&at)));
|
||||
if earlier {
|
||||
let Some(w) = waiting.iter().position(|(i, _)| *i == at) else { continue };
|
||||
let (_, reason) = waiting.swap_remove(w);
|
||||
// The name exactly as the `Do` writes it: a name need not be UTF-8, and
|
||||
// `image.name` is only its display form.
|
||||
if pass == MAX_LEVELS || !op.last_name().is_some_and(|name| ungroup::merges_without_renaming(&next, &merged, name)) {
|
||||
still_waiting.push((at, reason));
|
||||
continue;
|
||||
}
|
||||
// What reading it again finds replaces the reason it was given.
|
||||
if let Some(i) = result.refused.iter().position(|r| *r == reason) {
|
||||
result.refused.remove(i);
|
||||
}
|
||||
}
|
||||
if !earlier {
|
||||
visits = visits.saturating_add(1);
|
||||
if visits > 64 {
|
||||
return Err(EditError::Invalid("too many grouped text placements on this page (limit 64); the page was left unchanged".into()));
|
||||
}
|
||||
}
|
||||
match ungroup::ungroup_ops(&next, &merged, &ops, at, space.as_ref(), Matrix(image.matrix), &mut decode) {
|
||||
Ok(ungroup::Ungrouped { ops: inline, resources: mut res, patterns, shows_text }) => {
|
||||
// Vector and image artwork needs no preparation. Nested text groups are
|
||||
// visited on the next pass, with their inherited placement and clip in force.
|
||||
match shows_text {
|
||||
Some(true) => {}
|
||||
Some(false) => {
|
||||
settled.extend(image.object);
|
||||
continue;
|
||||
}
|
||||
// Whether it has text can't be known: it is left as it is, and said.
|
||||
None => {
|
||||
result.refused.push(format!("{}: it draws recursive forms, or nested content that can't be checked", image.name));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if pass == MAX_LEVELS {
|
||||
result.refused.push(format!("{}: its text is grouped more than {MAX_LEVELS} levels deep", image.name));
|
||||
continue;
|
||||
}
|
||||
let data_len = pdfcraft_content::serialize_ops(&inline).len();
|
||||
bytes_left = bytes_left.checked_sub(data_len).ok_or_else(|| {
|
||||
EditError::Invalid("grouped page content exceeds the 16 MiB editing budget; the page was left unchanged".into())
|
||||
})?;
|
||||
if !patterns.is_empty() {
|
||||
let mut table = res.get(b"Pattern").map(|r| next.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
for (name, value) in patterns {
|
||||
table.set(name, Object::Ref(next.add(value)));
|
||||
}
|
||||
res.set(b"Pattern".to_vec(), Object::Dict(table));
|
||||
}
|
||||
merged = res;
|
||||
replacements.insert(at, inline);
|
||||
result.expanded += 1;
|
||||
expanded_here.extend(image.object);
|
||||
}
|
||||
Err(refusal) => {
|
||||
let reason = format!("{}: {}", image.name, refusal.error);
|
||||
if refusal.renaming {
|
||||
still_waiting.push((at, reason.clone()));
|
||||
}
|
||||
if refusal.intrinsic {
|
||||
refused_here.extend(image.object);
|
||||
}
|
||||
result.refused.push(reason);
|
||||
}
|
||||
}
|
||||
}
|
||||
settled.extend(refused_here.difference(&expanded_here).copied());
|
||||
if replacements.is_empty() {
|
||||
break;
|
||||
}
|
||||
// Where an operator lands in the next pass: each replaced `Do` before it becomes its
|
||||
// inlined operators.
|
||||
let moved = |i: usize| replacements.range(..i).fold(i, |n, (_, inline)| n.saturating_add(inline.len()).saturating_sub(1));
|
||||
inlined = Some(Inlined {
|
||||
ops: moved(ops.len()),
|
||||
ranges: replacements.iter().map(|(at, inline)| moved(*at)..moved(*at).saturating_add(inline.len())).collect(),
|
||||
waiting: still_waiting.into_iter().map(|(at, reason)| (moved(at), reason)).collect(),
|
||||
});
|
||||
let changed = splice(&pieces, &ops, |at| match replacements.remove(&at) {
|
||||
Some(ops) => (ops, false),
|
||||
None => (Vec::new(), true),
|
||||
});
|
||||
let contents = rewritten_contents(&mut next, &streams, changed)?;
|
||||
next.update_dict(p.obj, |d| {
|
||||
d.set(b"Contents".to_vec(), contents);
|
||||
d.set(b"Resources".to_vec(), Object::Dict(merged));
|
||||
})?;
|
||||
}
|
||||
result.refused.sort();
|
||||
result.refused.dedup();
|
||||
// The page's own damaged maps are repaired too, those of text that was never grouped
|
||||
// included: its text reads wrong all the same.
|
||||
result.repaired_fonts = repair_thai_fonts(&mut next, page)?;
|
||||
if result.expanded > 0 || result.repaired_fonts > 0 {
|
||||
for reason in &result.refused {
|
||||
next.note_repair(format!("Grouped text left intact: {reason}"));
|
||||
}
|
||||
if result.expanded > 0 {
|
||||
next.note_repair(format!(
|
||||
"Prepared {} grouped text placement(s) on page {} for direct source-text editing",
|
||||
result.expanded,
|
||||
page.saturating_add(1)
|
||||
));
|
||||
}
|
||||
*doc = next;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Whether grouped artwork `image` on `page` (its index in `page_images`) shows text that
|
||||
/// [`prepare`] would make editable, read as its first pass reads it, without changing anything:
|
||||
/// a logo or a refused group is `false`. A group refused only because its resources need renaming
|
||||
/// is `true`, as another group's names can still let [`prepare`] open it.
|
||||
pub fn grouped_text_at(doc: &Document, page: usize, image: usize) -> Result<bool, EditError> {
|
||||
let Some(img) = crate::page_images(doc, page)?.into_iter().nth(image).filter(|i| i.is_form) else { return Ok(false) };
|
||||
let p = page_dict(doc, page)?;
|
||||
let parsed = joined(&strict_streams(doc, &p.dict)?).parse();
|
||||
if parsed.skipped > 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
let resources = p.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let space = ungroup::page_blend_space(doc, &p.dict);
|
||||
let mut budget = pdfcraft_cos::DecodeBudget::new(MAX_PREPARED_BYTES, 0);
|
||||
Ok(match ungroup::ungroup_ops(doc, &resources, &parsed.ops, img.op, space.as_ref(), Matrix(img.matrix), &mut budget) {
|
||||
Ok(u) => u.shows_text == Some(true),
|
||||
Err(r) => r.renaming,
|
||||
})
|
||||
}
|
||||
|
||||
/// A font is copied into this page: repairing the map must never alter a shared form/font.
|
||||
fn repair_thai_fonts(doc: &mut Document, page: usize) -> Result<usize, EditError> {
|
||||
let p = page_dict(doc, 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(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()).unwrap_or_default();
|
||||
let mut count = 0;
|
||||
let mut candidates = 0usize;
|
||||
for (name, value) in fonts.clone().iter() {
|
||||
let Some(mut font) = doc.resolve(value).as_dict().cloned() else { continue };
|
||||
if font.name(b"BaseFont").is_some_and(|n| String::from_utf8_lossy(n).contains("THSarabunPSK")) {
|
||||
candidates += 1;
|
||||
if candidates > 64 {
|
||||
return Err(EditError::Invalid("too many legacy Thai font maps to repair safely; the page was left unchanged".into()));
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
let Some(map) = pdfcraft_fonts::pdf::recovered_thai_map(doc, &font) else { continue };
|
||||
// Append corrective bfchar entries after the original entries so valid Latin mappings
|
||||
// remain. The original map's accidental oversized range is still overridden for Thai.
|
||||
let Some(Object::Stream(tu)) = font.get(b"ToUnicode").map(|r| doc.resolve(r)).as_deref().cloned() else { continue };
|
||||
let old = pdfcraft_cos::DecodeBudget::new(1 << 20, 0).decode_strict(doc, &tu)?;
|
||||
// After the last `endcmap`: a stream with more than one must not have an earlier block
|
||||
// (or its oversized range) after the corrective entries.
|
||||
let marker = b"endcmap";
|
||||
let Some(at) = old.windows(marker.len()).rposition(|w| w == marker) else { continue };
|
||||
let mut added = format!("\n{} beginbfchar\n", map.len());
|
||||
for (code, ch) in map {
|
||||
added.push_str(&format!("<{code:04x}> <{:04x}>\n", u32::from(ch)));
|
||||
}
|
||||
added.push_str("endbfchar\n");
|
||||
let mut bytes = old.get(..at).unwrap_or_default().to_vec();
|
||||
bytes.extend_from_slice(added.as_bytes());
|
||||
bytes.extend_from_slice(old.get(at..).unwrap_or_default());
|
||||
let tu = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &bytes)));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(tu));
|
||||
let object = doc.add(Object::Dict(font));
|
||||
fonts.set(name.clone(), Object::Ref(object));
|
||||
count += 1;
|
||||
}
|
||||
if count > 0 {
|
||||
res.set(b"Font".to_vec(), Object::Dict(fonts));
|
||||
doc.update_dict(p.obj, |d| d.set(b"Resources".to_vec(), Object::Dict(res)))?;
|
||||
doc.note_repair(format!(
|
||||
"Recovered the damaged legacy THSarabunPSK Thai character map in {count} page font(s); original glyph programs were preserved"
|
||||
));
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// Editing cannot infer the state after a damaged page stream. Every piece must decode whole
|
||||
/// before a placement is expanded, rather than treating an undecodable piece as empty.
|
||||
fn strict_streams(doc: &Document, page: &Dict) -> Result<Vec<ContentStream>, EditError> {
|
||||
let streams = content_streams(doc, page)?;
|
||||
if streams.iter().any(|s| s.read != Read::Whole) {
|
||||
return Err(EditError::Invalid("the page has a missing or unreadable content stream".into()));
|
||||
}
|
||||
Ok(streams)
|
||||
}
|
||||
@@ -749,12 +749,13 @@ struct ParaStyle {
|
||||
k: f64,
|
||||
}
|
||||
|
||||
/// Spacing and scaling (text state), with the chosen values.
|
||||
fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle), EditError> {
|
||||
/// Spacing and scaling (text state), with the chosen values. `phrase` is the size an OCR phrase
|
||||
/// is drawn at when none is chosen, in place of its first word's own (#3313).
|
||||
fn paragraph_style(o: &Origin, style: &BlockStyle, phrase: Option<f64>) -> Result<(String, ParaStyle), EditError> {
|
||||
let (font_name, old_size) = o.state.font.clone().ok_or_else(|| EditError::Invalid("the paragraph has no font".into()))?;
|
||||
let k = o.k.max(1e-6);
|
||||
// The size in text space: points ÷ the text-to-user scale.
|
||||
let size = style.size.map_or(old_size, |pt| (pt / k).max(0.1));
|
||||
let size = style.size.or(phrase).map_or(old_size, |pt| (pt / k).max(0.1));
|
||||
let mut state = o.state.clone();
|
||||
if let Some(c) = style.char_spacing {
|
||||
state.char_spacing = c / k;
|
||||
@@ -762,6 +763,11 @@ fn paragraph_style(o: &Origin, style: &BlockStyle) -> Result<(String, ParaStyle)
|
||||
if let Some(sc) = style.scale {
|
||||
state.scale = (sc / 100.0).clamp(0.1, 10.0);
|
||||
}
|
||||
// At the phrase's size, the first word's text matrix still carries its own width: scale it
|
||||
// back, so the line is as wide as the scan whatever size OCR guessed for that word.
|
||||
if phrase.is_some() && style.size.is_none() && style.scale.is_none() {
|
||||
state.scale = (state.scale * old_size / size).clamp(0.1, 10.0);
|
||||
}
|
||||
Ok((font_name, ParaStyle { state, size, old_size, k }))
|
||||
}
|
||||
|
||||
@@ -842,7 +848,7 @@ pub(crate) fn rewrite_block_from(
|
||||
let o = &first.origin;
|
||||
let last = members.last().copied().unwrap_or(first);
|
||||
let emit = &last.origin;
|
||||
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style)?;
|
||||
let (font_name, ParaStyle { state: o_state, size, old_size, k }) = paragraph_style(o, style, b.phrase_size())?;
|
||||
let source = source_font(doc, &fonts_res, &font_name, first, &flat, style);
|
||||
let reuse = source.reuse;
|
||||
// The chosen style also determines the real Japanese fallback outlines and advances.
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
|
||||
no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font, source_encodes, source_family,
|
||||
source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph, union, write_type3_glyphs,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, paragraph_separator, reveal_ops, rewritten_contents, same_base_font, same_font,
|
||||
source_encodes, source_family, source_program, source_show, source_space, source_width, splice, strip_subset_tag, type3_faces, type3_glyph,
|
||||
union, write_type3_glyphs,
|
||||
};
|
||||
|
||||
#[path = "text_regions/grouping.rs"]
|
||||
|
||||
@@ -398,6 +398,17 @@ fn group_layout(lines: &[TextLine], mixed: bool) -> Vec<TextBlock> {
|
||||
for block in &mut blocks {
|
||||
block.reflow_text = reflow_text(block, lines);
|
||||
}
|
||||
for block in blocks.iter_mut().filter(|block| block.invisible && block.lines.len() > 1) {
|
||||
// Each word counts once: neighbouring words OCR sized alike share a run.
|
||||
let words = block
|
||||
.lines
|
||||
.iter()
|
||||
.filter_map(|i| lines.get(*i))
|
||||
.flat_map(|line| std::iter::repeat_n(line.size, line.text.split_whitespace().count().max(1)));
|
||||
if let Some(size) = median_size(words) {
|
||||
block.size = size;
|
||||
}
|
||||
}
|
||||
blocks
|
||||
}
|
||||
|
||||
@@ -452,3 +463,14 @@ fn break_gap(block: &TextBlock, lines: &[TextLine], row_index: usize) -> Option<
|
||||
// measure leaves almost no room for the next word.
|
||||
(slack <= size * 0.75 && width >= size * 8.0 && (next[2] - next[0]) + size * 0.25 > slack).then_some(1)
|
||||
}
|
||||
|
||||
/// The size an OCR phrase is set in. Recognize text sizes each word from its own box and a guess
|
||||
/// at how much of the em its letters fill, so one phrase's words come out several points apart
|
||||
/// (#3313); a rewrite draws the phrase at this one size. `None` when no size is usable.
|
||||
fn median_size(sizes: impl Iterator<Item = f64>) -> Option<f64> {
|
||||
let mut sizes: Vec<f64> = sizes.filter(|size| size.is_finite() && *size > 0.0).collect();
|
||||
sizes.sort_by(f64::total_cmp);
|
||||
let middle = sizes.len() / 2;
|
||||
let upper = sizes.get(middle).copied()?;
|
||||
if sizes.len().is_multiple_of(2) { sizes.get(middle.saturating_sub(1)).map(|lower| (lower + upper) / 2.0) } else { Some(upper) }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use super::super::interp::TextState;
|
||||
use super::grouping::{across, is_centred, row_rect};
|
||||
use super::{
|
||||
BlockStyle, CraftFont, Dict, Document, EditError, HashMap, LineEdit, MAX_TYPE3_GLYPHS, Matrix, Metrics, Object, Op, PdfString, Pieces,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, coincident_ops, content_streams, fallback_face, japanese_glyph_from, needs_type3, no_fallback_font,
|
||||
no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program, source_show, source_space, source_width,
|
||||
splice, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
RangeStyle, TextBlock, TextLine, Type3Glyph, builtin_thai_choice, coincident_ops, content_streams, fallback_face, japanese_glyph_from,
|
||||
needs_type3, no_fallback_font, no_glyph_for, page_dict, reveal_ops, rewritten_contents, source_encodes, source_family, source_program,
|
||||
source_show, source_space, source_width, splice, type3_faces, type3_glyph, write_type3_glyphs,
|
||||
};
|
||||
|
||||
// The draft is capped at 4 MiB; also bound the outline data prepared across all fallback faces.
|
||||
@@ -104,9 +104,46 @@ impl FontResolver<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
struct LegacyFace {
|
||||
/// Shared by every resource name of one font object: its program is decoded and kept once.
|
||||
glyphs: Option<std::rc::Rc<pdfcraft_fonts::pdf::LegacyThaiFont>>,
|
||||
metrics: Metrics,
|
||||
/// Weight and slant ([`FontStyle::of`]).
|
||||
style: FontStyle,
|
||||
/// The font object the resource names, when it is given by reference.
|
||||
object: Option<pdfcraft_cos::ObjRef>,
|
||||
/// Its text-space fallback ([`source_space`]), in ems.
|
||||
space: f64,
|
||||
}
|
||||
|
||||
/// What one edit may decode, and so keep, of the font programs of its THSarabunPSK subsets
|
||||
/// (each at most 16 MiB): a page may name many of them.
|
||||
const MAX_THAI_PROGRAMS: usize = 64 << 20;
|
||||
|
||||
struct PreparedCharacters {
|
||||
values: Vec<StyledChar>,
|
||||
substituted: Option<String>,
|
||||
thai: ThaiShaping,
|
||||
}
|
||||
|
||||
/// How the Thai spans of an edit are shaped, by the font name their characters carry.
|
||||
#[derive(Default)]
|
||||
struct ThaiShaping {
|
||||
/// Draft-only names drawn with the bundled Thai outlines.
|
||||
outlined: std::collections::HashSet<String>,
|
||||
/// Source faces shaped in their existing codes, by resource name.
|
||||
legacy: HashMap<String, LegacyFace>,
|
||||
/// Draft-only names whose rows, Latin ones included, are shaped and embedded with the
|
||||
/// complete program of this same-face resource: a span its subsets can't show in place.
|
||||
program: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// How a Thai span keeps its source face ([`legacy_thai_font`]).
|
||||
enum ThaiSpan {
|
||||
/// In this resource's existing codes.
|
||||
InPlace(String),
|
||||
/// Shaped by this same-face resource's complete program with its own layout.
|
||||
Program(String),
|
||||
}
|
||||
|
||||
struct CharacterPreparation<'a> {
|
||||
@@ -125,12 +162,93 @@ struct CharacterPreparation<'a> {
|
||||
impl CharacterPreparation<'_> {
|
||||
fn prepare(self) -> Result<PreparedCharacters, EditError> {
|
||||
let Self { doc, first, lines, draft, spans, style, overrides, fonts, metrics, max_glyphs } = self;
|
||||
let thai_choice = builtin_thai_choice(fonts);
|
||||
let mut thai = ThaiShaping::default();
|
||||
let mut thai_programs = pdfcraft_cos::DecodeBudget::new(MAX_THAI_PROGRAMS, 0);
|
||||
let mut chars = Vec::new();
|
||||
let mut substituted = None;
|
||||
let mut fallback = FallbackBuilder::new(max_glyphs);
|
||||
let mut resolver = FontResolver { doc, fonts, metrics, full_faces: HashMap::new(), programs: HashMap::new() };
|
||||
for (range, index) in spans {
|
||||
let source = lines.get(index).unwrap_or(first);
|
||||
let span = draft.get(range.clone()).unwrap_or_default();
|
||||
// Keep the exact source face. A different Thai face requires an explicit choice.
|
||||
// Interface fallback fonts never enter a PDF.
|
||||
let selected_resource = overrides.get(&index).and_then(|r| r.resource.as_deref()).or(style.resource.as_deref());
|
||||
let use_thai_outlines = if selected_resource == Some(thai_choice.as_str()) {
|
||||
let face = pdfcraft_fonts::document_thai_font().ok_or_else(|| {
|
||||
EditError::Invalid(format!(
|
||||
"the bundled Thai font ({thai_choice}) is not in this build, which has no craft-fonts Thai face; load a complete Thai font or choose one the page lists"
|
||||
))
|
||||
})?;
|
||||
if !pdfcraft_fonts::embedding::covers(face.bytes, span) {
|
||||
let missing = span.chars().find(|c| !pdfcraft_fonts::embedding::covers(face.bytes, c.encode_utf8(&mut [0; 4])));
|
||||
return Err(EditError::Invalid(match missing {
|
||||
Some(c) => format!("{} has no glyph for {c} (U+{:04X}); choose a font that covers the text", face.family, u32::from(c)),
|
||||
None => format!("{} cannot show every character in this selection; choose a font that covers the text", face.family),
|
||||
}));
|
||||
}
|
||||
substituted = Some(face.family.to_owned());
|
||||
true
|
||||
} else {
|
||||
selected_resource.is_none() && matching_thai_outline(resolver.doc, resolver.fonts, source, span)
|
||||
};
|
||||
if use_thai_outlines {
|
||||
substituted = pdfcraft_fonts::document_thai_font().map(|f| f.family.to_owned());
|
||||
}
|
||||
let existing_thai = if !use_thai_outlines {
|
||||
let chosen = match selected_resource {
|
||||
None => Some(source.clone()),
|
||||
Some(resource) => resolver.fonts.get(resource.as_bytes()).and_then(|f| resolver.doc.dict(f)).map(|font| {
|
||||
let mut chosen = source.clone();
|
||||
chosen.font = resource.to_owned();
|
||||
chosen.base_font = Metrics::from_dict(resolver.doc, &font).base_font;
|
||||
chosen
|
||||
}),
|
||||
};
|
||||
match chosen {
|
||||
Some(chosen) => legacy_thai_font(resolver.doc, resolver.fonts, &chosen, span, &mut thai.legacy, &mut thai_programs)?,
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let outline_name = if use_thai_outlines {
|
||||
// A draft-only name prevents this choice from affecting another style span that
|
||||
// explicitly selects the original resource. It is never saved into the PDF.
|
||||
let mut name = format!("PCOutlineThai{index}");
|
||||
while resolver.metrics.contains_key(&name) {
|
||||
name.push('_');
|
||||
}
|
||||
if let Some(m) = resolver
|
||||
.metrics
|
||||
.get(&source.font)
|
||||
.or_else(|| source.origin.state.font.as_ref().and_then(|(name, _)| resolver.metrics.get(name)))
|
||||
.cloned()
|
||||
{
|
||||
resolver.metrics.insert(name.clone(), m);
|
||||
}
|
||||
thai.outlined.insert(name.clone());
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let (existing_thai, program_name) = match existing_thai {
|
||||
Some(ThaiSpan::InPlace(name)) => (Some(name), None),
|
||||
Some(ThaiSpan::Program(resource)) => {
|
||||
// Draft-only too: another span may still show this resource's existing codes.
|
||||
let mut name = format!("PCProgramThai{index}");
|
||||
while resolver.metrics.contains_key(&name) {
|
||||
name.push('_');
|
||||
}
|
||||
if let Some(m) = resolver.metrics.get(&source.font).cloned() {
|
||||
resolver.metrics.insert(name.clone(), m);
|
||||
}
|
||||
thai.program.insert(name.clone(), resource);
|
||||
(None, Some(name))
|
||||
}
|
||||
None => (None, None),
|
||||
};
|
||||
let start = range.start;
|
||||
for (offset, ch) in draft.get(range).unwrap_or_default().char_indices() {
|
||||
if ch == '\n' {
|
||||
@@ -144,7 +262,11 @@ impl CharacterPreparation<'_> {
|
||||
}
|
||||
let text = ch.to_string();
|
||||
let requested = overrides.get(&index).and_then(|range| range.resource.as_deref()).or(style.resource.as_deref());
|
||||
let name = if let Some(name) = resolver.encodable_font(source, &text, requested)? {
|
||||
let name = if let Some(name) = outline_name.as_ref().or(program_name.as_ref()) {
|
||||
name.clone()
|
||||
} else if let Some(name) = &existing_thai {
|
||||
name.clone()
|
||||
} else if let Some(name) = resolver.encodable_font(source, &text, requested)? {
|
||||
name
|
||||
} else if needs_type3(&text) {
|
||||
fallback.prepare(source, ch, &text, chars.len())?
|
||||
@@ -173,7 +295,7 @@ impl CharacterPreparation<'_> {
|
||||
}
|
||||
substituted = Some(written.family.to_owned());
|
||||
}
|
||||
Ok(PreparedCharacters { values: chars, substituted })
|
||||
Ok(PreparedCharacters { values: chars, substituted, thai })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -233,7 +355,16 @@ struct StyledLines<'a> {
|
||||
type StyledChar = (char, usize, String);
|
||||
type StyledRows = Vec<Vec<StyledChar>>;
|
||||
type RowRanges = Vec<std::ops::Range<usize>>;
|
||||
type ShapedRunCache = HashMap<(usize, usize), (Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>;
|
||||
enum ShapedFont {
|
||||
Existing(Vec<(String, Vec<u8>)>),
|
||||
Program { bytes: Vec<u8>, family: String },
|
||||
ThaiOutlines,
|
||||
}
|
||||
struct ShapedText {
|
||||
run: pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
font: ShapedFont,
|
||||
}
|
||||
type ShapedRunCache = HashMap<(usize, usize), ShapedText>;
|
||||
|
||||
struct ShapedRows {
|
||||
runs: ShapedRunCache,
|
||||
@@ -243,6 +374,7 @@ struct ShapedRows {
|
||||
fn prepare_shaped_rows(
|
||||
doc: &Document,
|
||||
fonts: &Dict,
|
||||
thai: &ThaiShaping,
|
||||
rows: &[Vec<StyledChar>],
|
||||
kept: &[bool],
|
||||
advance: impl Fn(&StyledChar) -> f64,
|
||||
@@ -259,8 +391,8 @@ fn prepare_shaped_rows(
|
||||
let segment = row.get(at..end).unwrap_or_default();
|
||||
advances.insert((row_index, at), segment.iter().map(&advance).sum::<f64>());
|
||||
let text: String = segment.iter().map(|character| character.0).collect();
|
||||
if let Some((bytes, shaped, family)) = shape_source(doc, fonts, &item.2, &text)? {
|
||||
runs.insert((row_index, at), (bytes, shaped, family));
|
||||
if let Some(shaped) = shape_source(doc, fonts, thai, &item.2, &text)? {
|
||||
runs.insert((row_index, at), shaped);
|
||||
}
|
||||
at = end;
|
||||
}
|
||||
@@ -268,16 +400,138 @@ fn prepare_shaped_rows(
|
||||
Ok(ShapedRows { runs, advances })
|
||||
}
|
||||
|
||||
fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result<HashMap<(usize, usize), String>, EditError> {
|
||||
type ShapedResources = HashMap<(usize, usize), Vec<(String, Vec<u8>)>>;
|
||||
|
||||
fn embed_shaped_resources(doc: &mut Document, fonts: &mut Dict, runs: &ShapedRunCache) -> Result<ShapedResources, EditError> {
|
||||
let mut resources = HashMap::new();
|
||||
for (key, (bytes, shaped, family)) in runs {
|
||||
let mappings: Vec<_> = shaped.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
resources.insert(*key, resource);
|
||||
for (key, shaped) in runs {
|
||||
let glyphs = match &shaped.font {
|
||||
ShapedFont::Existing(codes) => codes.clone(),
|
||||
ShapedFont::ThaiOutlines => crate::added::thai_glyph_resources(doc, fonts, &shaped.run)?,
|
||||
ShapedFont::Program { bytes, family } => {
|
||||
let mappings: Vec<_> = shaped.run.glyphs.iter().map(|glyph| (glyph.text.clone(), glyph.id)).collect();
|
||||
let resource = pdfcraft_fonts::embedding::embed_mapping(doc, fonts, bytes, family, &mappings).map_err(EditError::Invalid)?;
|
||||
(0..shaped.run.glyphs.len())
|
||||
.map(|i| {
|
||||
let code = u16::try_from(i.saturating_add(1)).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
Ok((resource.clone(), code.to_be_bytes().to_vec()))
|
||||
})
|
||||
.collect::<Result<Vec<_>, EditError>>()?
|
||||
}
|
||||
};
|
||||
resources.insert(*key, glyphs);
|
||||
}
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
/// A positioned glyph run, kept in one text object across Type 3 font shards.
|
||||
struct ShapedRunDrawing<'a> {
|
||||
doc: &'a Document,
|
||||
fonts: &'a Dict,
|
||||
shaped: &'a pdfcraft_fonts::source_shaping::ShapedRun,
|
||||
resources: &'a [(String, Vec<u8>)],
|
||||
text: &'a str,
|
||||
matrix: [f64; 6],
|
||||
size: f64,
|
||||
thai: bool,
|
||||
}
|
||||
|
||||
impl ShapedRunDrawing<'_> {
|
||||
/// Draw and return the run's advance in text space, including cluster-safe spacing.
|
||||
fn draw(&self, block_ops: &mut Vec<Op>, state: &mut TextState) -> Result<f64, EditError> {
|
||||
let Self { doc, fonts, shaped, resources, text, matrix: tm, size, thai } = *self;
|
||||
let n = pdfcraft_content::num;
|
||||
let (resource, _) = self.resources.first().ok_or_else(|| EditError::Invalid("shaped run has no glyphs".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(text)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
state.font = Some((resource.clone(), size));
|
||||
// One text object for the run: each glyph is placed where shaping put it by a TJ
|
||||
// adjustment from where the glyph before it ends (its advance in the embedded
|
||||
// font's widths), and raised by the text rise for a mark set above or below
|
||||
// (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode;
|
||||
// ToUnicode and ActualText keep the logical text.
|
||||
let per_unit = 1000.0 / size;
|
||||
if !per_unit.is_finite() {
|
||||
return Err(EditError::Invalid("the shaped text has no size".into()));
|
||||
}
|
||||
let mut shown_fonts = HashMap::new();
|
||||
for (name, _) in resources {
|
||||
if !shown_fonts.contains_key(name) {
|
||||
let font = fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
shown_fonts.insert(name.clone(), Metrics::from_dict(doc, &font));
|
||||
}
|
||||
}
|
||||
// Keep the original text settings. TJ compensates for the automatic PDF advance
|
||||
// after each glyph, while shaping keeps marks attached to their cluster.
|
||||
let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise);
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
|
||||
// `pen`: where the next glyph would start, in thousandths of the font size.
|
||||
let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise);
|
||||
let mut shown_glyphs: Vec<Object> = Vec::new();
|
||||
let mut cluster_spacing = 0.0;
|
||||
let mut current_resource = resource.as_str();
|
||||
for (i, glyph) in shaped.glyphs.iter().enumerate() {
|
||||
let (name, code) = resources.get(i).ok_or_else(|| EditError::Invalid("shaped glyph resource is missing".into()))?;
|
||||
let shown = shown_fonts.get(name).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
if name != current_resource {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
block_ops.push(Op::new("Tf", vec![Object::name(name), n(size)]));
|
||||
current_resource = name;
|
||||
}
|
||||
if thai && !glyph.text.is_empty() {
|
||||
// Apply spacing only after a whole cluster, keeping marks on their base.
|
||||
extra += cluster_spacing;
|
||||
cluster_spacing = char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
let target = (glyph.position[0] * size + extra) * per_unit;
|
||||
let glyph_rise = rise + glyph.position[1] * size;
|
||||
if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
if (glyph_rise - raised).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(glyph_rise)]));
|
||||
raised = glyph_rise;
|
||||
}
|
||||
}
|
||||
if (target - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - target));
|
||||
}
|
||||
shown_glyphs.push(Object::String(PdfString::literal(code.clone())));
|
||||
let cid = code.iter().fold(0_u32, |value, byte| value.saturating_mul(256).saturating_add(u32::from(*byte)));
|
||||
pen = target
|
||||
+ if code.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
shown.width(cid) * 1000.0 + (char_spacing + if shown.is_space(cid, code.len()) { word_spacing } else { 0.0 }) * per_unit
|
||||
};
|
||||
if !thai {
|
||||
extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
}
|
||||
let end = (shaped.advance * size + extra + cluster_spacing) * per_unit;
|
||||
if (end - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - end));
|
||||
}
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)]));
|
||||
}
|
||||
if (raised - rise).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(rise)]));
|
||||
}
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
(state.char_spacing, state.word_spacing) = (char_spacing, word_spacing);
|
||||
extra += cluster_spacing;
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
Ok(shaped.advance * size + extra)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_contextual_rows(rows: &[Vec<StyledChar>], kept: &[bool]) -> Result<(), EditError> {
|
||||
for (row, _) in rows.iter().zip(kept).filter(|(_, retained)| !**retained) {
|
||||
let text: String = row.iter().map(|character| character.0).collect();
|
||||
@@ -321,6 +575,23 @@ fn calibration_factors(
|
||||
calibration
|
||||
}
|
||||
|
||||
/// The calibration factors fit each word to its box at its own size (its `Tf` in its text space,
|
||||
/// which a range's size doesn't change). An OCR phrase drawn at one size needs the inverse ratio
|
||||
/// too, so every word still spans the box the scan shows it in. The ratio is not clamped again:
|
||||
/// a word OCR sized at three times the phrase's needs three times the calibration.
|
||||
fn fit_to_phrase(calibration: &mut HashMap<usize, f64>, lines: &[TextLine], phrase: Option<f64>) {
|
||||
let Some(phrase) = phrase else { return };
|
||||
for (index, factor) in calibration.iter_mut() {
|
||||
let own = lines.get(*index).and_then(|source| Some(source.origin.state.font.as_ref()?.1 * source.origin.k));
|
||||
if let Some(own) = own.filter(|own| own.is_finite() && *own > 0.0) {
|
||||
let fitted = *factor * own / phrase;
|
||||
if fitted.is_finite() {
|
||||
*factor = fitted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validated_ranges<'a>(draft: &str, style: &'a BlockStyle) -> Result<Vec<&'a RangeStyle>, EditError> {
|
||||
let mut ranges: Vec<_> = style.ranges.iter().collect();
|
||||
ranges.sort_by_key(|range| range.range.start);
|
||||
@@ -578,35 +849,385 @@ fn apply_range_styles<'a>(
|
||||
StyledLines { lines: styled_lines, spans: styled_spans, underlines, overrides }
|
||||
}
|
||||
|
||||
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
|
||||
fn shape_source(
|
||||
/// Select one existing subset of the exact source face for a Thai span. Never replace its
|
||||
/// family/weight to make an edit succeed; missing glyphs need an explicit font choice. The
|
||||
/// subsets' programs are read within `budget`, shared by the edit's spans.
|
||||
fn legacy_thai_font(
|
||||
doc: &Document,
|
||||
fonts: &Dict,
|
||||
name: &str,
|
||||
text: &str,
|
||||
) -> Result<Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun, String)>, EditError> {
|
||||
source: &TextLine,
|
||||
span: &str,
|
||||
cache: &mut HashMap<String, LegacyFace>,
|
||||
budget: &mut pdfcraft_cos::DecodeBudget,
|
||||
) -> Result<Option<ThaiSpan>, EditError> {
|
||||
if !source.base_font.rsplit('+').next().unwrap_or(&source.base_font).starts_with("THSarabunPSK")
|
||||
|| !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
let mut candidates = vec![source.font.clone()];
|
||||
candidates.extend(fonts.iter().filter_map(|(name, font)| {
|
||||
let font = doc.dict(font)?;
|
||||
(pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, &font).base_font) == family).then(|| String::from_utf8_lossy(name).into_owned())
|
||||
}));
|
||||
for name in candidates.into_iter().take(128) {
|
||||
if cache.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
let Some(resource) = fonts.get(name.as_bytes()) else { continue };
|
||||
let Some(font) = doc.dict(resource) else { continue };
|
||||
let object = resource.as_ref();
|
||||
let face = match object.and_then(|o| cache.values().find(|face| face.object == Some(o))) {
|
||||
Some(alias) => LegacyFace { glyphs: alias.glyphs.clone(), metrics: alias.metrics.clone(), object, ..*alias },
|
||||
None => {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
let space = source_space(doc, fonts, &metrics);
|
||||
let glyphs = pdfcraft_fonts::pdf::LegacyThaiFont::from_pdf(doc, &font, space, budget).ok().flatten().map(std::rc::Rc::new);
|
||||
let style = FontStyle::of(doc, &font, &metrics);
|
||||
LegacyFace { glyphs, metrics, style, object, space }
|
||||
}
|
||||
};
|
||||
cache.insert(name, face);
|
||||
}
|
||||
// A subset left unread would count as unusable, silently changing which subset draws the text.
|
||||
if budget.remaining() == 0 {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"the page's {} subsets need more than {} MiB of font programs for one edit; choose a replacement font for this text",
|
||||
source.base_font.rsplit('+').next().unwrap_or(&source.base_font),
|
||||
MAX_THAI_PROGRAMS >> 20
|
||||
)));
|
||||
}
|
||||
// A face none of whose subsets can be shaped in place (no Thai, or neither positioned nor in
|
||||
// the legacy glyph order) is shaped like any other source font (`shape_source`).
|
||||
if !has_thai_source_face(cache, &family) {
|
||||
return Ok(None);
|
||||
}
|
||||
let rows = || span.split(['\n', '\r']).filter(|row| !row.is_empty());
|
||||
for row in rows() {
|
||||
if let Err(error) = shape_existing_thai(cache, &source.font, row) {
|
||||
// Not in the face's existing codes. A complete program of the face that shapes every
|
||||
// row with its own layout (one Load font added as CFF, or whose contextual glyphs it
|
||||
// gave no code yet) takes the whole span, so no cluster is split between fonts.
|
||||
let Some(source_font) = fonts.get(source.font.as_bytes()).and_then(|f| doc.dict(f)) else { return Err(error) };
|
||||
let complete = program_candidates(doc, fonts, &source.font, source_font, &source.base_font)
|
||||
.into_iter()
|
||||
.find(|(_, font)| rows().all(|row| shape_with_program(doc, font, row).is_some()));
|
||||
return match complete {
|
||||
Some((name, _)) => Ok(Some(ThaiSpan::Program(name))),
|
||||
None => Err(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
Ok(Some(ThaiSpan::InPlace(source.font.clone())))
|
||||
}
|
||||
|
||||
/// Whether a subset of the face `family` (a [`face_key`](pdfcraft_fonts::embedding::face_key))
|
||||
/// in `cache` can be shaped in place: a THSarabunPSK program with its own Thai mark positioning,
|
||||
/// or a verified legacy positional one.
|
||||
fn has_thai_source_face(cache: &HashMap<String, LegacyFace>, family: &str) -> bool {
|
||||
cache.values().any(|face| face.glyphs.is_some() && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family)
|
||||
}
|
||||
|
||||
/// A visual row may use several subsets of the same exact face (often a separate subset for
|
||||
/// punctuation). Shape Thai clusters intact, and retain each glyph's original PDF font/code.
|
||||
fn shape_existing_thai(cache: &HashMap<String, LegacyFace>, primary: &str, text: &str) -> Result<ShapedText, EditError> {
|
||||
if text.len() > 262_144 {
|
||||
return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into()));
|
||||
}
|
||||
let first = cache.get(primary).ok_or_else(|| EditError::Invalid("the Thai source face is missing".into()))?;
|
||||
let family = pdfcraft_fonts::embedding::face_key(&first.metrics.base_font);
|
||||
let mut candidates = vec![(primary, first)];
|
||||
candidates.extend(
|
||||
cache
|
||||
.iter()
|
||||
.filter(|(name, face)| {
|
||||
name.as_str() != primary && face.style == first.style && pdfcraft_fonts::embedding::face_key(&face.metrics.base_font) == family
|
||||
})
|
||||
.map(|(name, face)| (name.as_str(), face)),
|
||||
);
|
||||
// Stable source-first choice, then resource name; never depend on hash iteration order.
|
||||
if let Some(tail) = candidates.get_mut(1..) {
|
||||
tail.sort_by_key(|(name, _)| *name);
|
||||
}
|
||||
let thai = |c: char| matches!(u32::from(c), 0x0e01..=0x0e5b | 0xf700..=0xf71a);
|
||||
let mut run = pdfcraft_fonts::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 };
|
||||
let mut resources = Vec::new();
|
||||
let mut chars = text.char_indices().peekable();
|
||||
while let Some((at, ch)) = chars.next() {
|
||||
if thai(ch) {
|
||||
let mut end = at + ch.len_utf8();
|
||||
while let Some(&(next, c)) = chars.peek() {
|
||||
if !thai(c) {
|
||||
break;
|
||||
}
|
||||
chars.next();
|
||||
end = next + c.len_utf8();
|
||||
}
|
||||
let segment = text.get(at..end).ok_or_else(|| EditError::Invalid("invalid Thai character boundary".into()))?;
|
||||
let unshaped = |why: String| {
|
||||
EditError::Invalid(format!(
|
||||
"{} cannot keep this edit in the original face: {}; load the complete original font or explicitly choose a replacement",
|
||||
first.metrics.base_font,
|
||||
if why.is_empty() { "its source glyph program is incomplete" } else { &why }
|
||||
))
|
||||
};
|
||||
// The whole run in one subset keeps any kerning between its clusters. A subsetter may
|
||||
// instead have split the run's glyphs across subsets of the face: then each cluster
|
||||
// (a base and the marks on it, which shape together) comes from a subset that has it.
|
||||
let pieces = match shape_in_one_subset(&candidates, segment) {
|
||||
Ok(whole) => vec![whole],
|
||||
Err(_) => thai_clusters(segment)
|
||||
.into_iter()
|
||||
.map(|cluster| shape_in_one_subset(&candidates, cluster).map_err(unshaped))
|
||||
.collect::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
for (name, shaped) in pieces {
|
||||
for (mut glyph, code) in shaped.shaped.glyphs.into_iter().zip(shaped.codes) {
|
||||
glyph.position[0] += run.advance;
|
||||
resources.push((name.to_owned(), code));
|
||||
run.glyphs.push(glyph);
|
||||
}
|
||||
run.advance += shaped.shaped.advance;
|
||||
}
|
||||
} else {
|
||||
let logical = ch.to_string();
|
||||
let found = candidates.iter().find_map(|(name, face)| {
|
||||
let code = face.metrics.encode(&logical)?;
|
||||
let first = face.metrics.codes(&code).first()?.0;
|
||||
// `encode` accepts a CID the subset emptied (#1443): drawing it would show nothing.
|
||||
// Try the next subset; a space then falls back to its measured gap below.
|
||||
if face.metrics.has_outline(first) == Some(false) {
|
||||
return None;
|
||||
}
|
||||
Some((*name, code, face.metrics.width(first)))
|
||||
});
|
||||
let (name, code, width) = if let Some(found) = found {
|
||||
found
|
||||
} else if ch == ' ' {
|
||||
// The source subset may omit spaces. Use a gap measured from a matching
|
||||
// document subset, then the face's existing text-space fallback.
|
||||
let width = candidates
|
||||
.iter()
|
||||
.find_map(|(_, f)| {
|
||||
let code = f.metrics.encode(" ")?;
|
||||
Some(f.metrics.width(f.metrics.codes(&code).first()?.0))
|
||||
})
|
||||
.unwrap_or(first.space);
|
||||
(primary, Vec::new(), width)
|
||||
} else {
|
||||
return Err(EditError::Invalid(format!(
|
||||
"{} has no source glyph for {ch} (U+{:04X}); load the complete original face or explicitly select a replacement",
|
||||
first.metrics.base_font,
|
||||
u32::from(ch)
|
||||
)));
|
||||
};
|
||||
run.glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph { id: Default::default(), position: [run.advance, 0.0], text: logical });
|
||||
resources.push((name.to_owned(), code));
|
||||
run.advance += width;
|
||||
}
|
||||
if run.glyphs.len() > 65_534 || !run.advance.is_finite() {
|
||||
return Err(EditError::Invalid("the Thai source row exceeds its shaping budget".into()));
|
||||
}
|
||||
}
|
||||
Ok(ShapedText { run, font: ShapedFont::Existing(resources) })
|
||||
}
|
||||
|
||||
/// Shape `text` in the first candidate subset that has every glyph it needs, and name it. The
|
||||
/// error is the first subset's reason (empty when no candidate has a usable glyph program).
|
||||
fn shape_in_one_subset<'a>(candidates: &[(&'a str, &LegacyFace)], text: &str) -> Result<(&'a str, pdfcraft_fonts::pdf::ExistingThaiRun), String> {
|
||||
let mut why = String::new();
|
||||
for (name, face) in candidates {
|
||||
let Some(font) = &face.glyphs else { continue };
|
||||
match font.shape(text) {
|
||||
Ok(shaped) => return Ok((name, shaped)),
|
||||
Err(e) if why.is_empty() => why = e,
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
Err(why)
|
||||
}
|
||||
|
||||
/// `text` (a run of Thai characters) cut before each character that doesn't attach to the one
|
||||
/// before it: a cluster is a base or leading vowel with the vowels and tone marks above and below
|
||||
/// it, and Sara Am, whose Nikhahit sits on the base. Legacy presentation forms (U+F700–U+F71A)
|
||||
/// are kept with what precedes them.
|
||||
fn thai_clusters(text: &str) -> Vec<&str> {
|
||||
let attaches = |c: char| matches!(u32::from(c), 0x0e31 | 0x0e33..=0x0e3a | 0x0e47..=0x0e4e | 0xf700..=0xf71a);
|
||||
let mut starts: Vec<usize> = text.char_indices().filter(|(at, c)| *at == 0 || !attaches(*c)).map(|(at, _)| at).collect();
|
||||
starts.push(text.len());
|
||||
// Each boundary is a `char_indices` position or the end, strictly increasing: every `get`
|
||||
// succeeds and no cluster is empty.
|
||||
starts.windows(2).filter_map(|w| text.get(*w.first()?..*w.get(1)?)).collect()
|
||||
}
|
||||
|
||||
/// Whether the approved Thai face can replace this source, whose embedded subset cannot
|
||||
/// shape the replacement. A usable complete source program always takes precedence.
|
||||
fn matching_thai_outline(doc: &Document, fonts: &Dict, source: &TextLine, span: &str) -> bool {
|
||||
let Some(face) = pdfcraft_fonts::document_thai_font() else { return false };
|
||||
// The bundled face is Regular and upright. A source its descriptor or name marks bold or
|
||||
// italic keeps that style, so completing it with these outlines would be a silent change:
|
||||
// it needs an explicit choice instead. (Variable-font instances often keep the family name.)
|
||||
let style = fonts
|
||||
.get(source.font.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.map_or(FontStyle { bold: source.bold, italic: source.italic }, |f| FontStyle::of(doc, &f, &Metrics::from_dict(doc, &f)));
|
||||
if style.bold || style.italic {
|
||||
return false;
|
||||
}
|
||||
if !span.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
|| !pdfcraft_fonts::embedding::matching_faces(&source.base_font).iter().any(|f| std::ptr::eq(*f, face))
|
||||
|| !pdfcraft_fonts::embedding::covers(face.bytes, span)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
// A registered complete matching program also completes the source subset.
|
||||
// Do not replace it with bundled outlines merely because the original resource is a subset.
|
||||
// Only one in the source's style does (`program_candidates`), so only that one counts here.
|
||||
let family = pdfcraft_fonts::embedding::face_key(&source.base_font);
|
||||
!fonts.iter().filter_map(|(_, font)| doc.dict(font)).any(|font| {
|
||||
let metrics = Metrics::from_dict(doc, &font);
|
||||
pdfcraft_fonts::embedding::face_key(&metrics.base_font) == family
|
||||
&& FontStyle::of(doc, &font, &metrics) == style
|
||||
&& pdfcraft_fonts::pdf::embedded_sfnt(doc, &font).is_some_and(|bytes| {
|
||||
pdfcraft_fonts::embedding::validate(&bytes).is_ok()
|
||||
&& pdfcraft_fonts::source_shaping::has_thai_layout(&bytes)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, span)
|
||||
&& span.split(['\n', '\r']).all(|row| pdfcraft_fonts::source_shaping::shape(&bytes, row).is_ok())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Use the same Thai script selection and mark/cluster positioning as Add Text, including
|
||||
/// rows beginning with Latin or digits (guessing from their first character would select Latin).
|
||||
fn shape_thai_row(text: &str) -> Result<pdfcraft_fonts::source_shaping::ShapedRun, EditError> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err(EditError::Invalid("shape one bounded text row at a time".into()));
|
||||
}
|
||||
let clusters =
|
||||
pdfcraft_fonts::shape_thai(text).map_err(|e| EditError::Invalid(format!("the matching Thai font cannot shape this text ({e:?})")))?;
|
||||
let mut glyphs = Vec::new();
|
||||
let mut advance = 0.0;
|
||||
for cluster in clusters {
|
||||
for (i, (id, [x, y])) in cluster.glyphs.into_iter().enumerate() {
|
||||
if glyphs.len() >= 65_534 {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
glyphs.push(pdfcraft_fonts::source_shaping::ShapedGlyph {
|
||||
id: id.into(),
|
||||
position: [advance + x, y],
|
||||
text: if i == 0 { cluster.text.clone() } else { String::new() },
|
||||
});
|
||||
}
|
||||
advance += cluster.advance;
|
||||
}
|
||||
if !advance.is_finite() {
|
||||
return Err(EditError::Invalid("shaped row exceeds its geometry budget".into()));
|
||||
}
|
||||
Ok(pdfcraft_fonts::source_shaping::ShapedRun { glyphs, advance })
|
||||
}
|
||||
|
||||
/// The resources whose complete program may shape text in the face of `base_font`: `source`
|
||||
/// (the resource `name`) first, then the page's other fonts of that face (at most 128).
|
||||
fn program_candidates(doc: &Document, fonts: &Dict, name: &str, source: Dict, base_font: &str) -> Vec<(String, Dict)> {
|
||||
let key = pdfcraft_fonts::embedding::face_key(base_font);
|
||||
let style = FontStyle::of(doc, &source, &Metrics::from_dict(doc, &source));
|
||||
let mut candidates = vec![(name.to_owned(), source)];
|
||||
candidates.extend(
|
||||
fonts
|
||||
.iter()
|
||||
.filter_map(|(n, f)| Some((String::from_utf8_lossy(n).into_owned(), doc.dict(f)?)))
|
||||
.filter(|(_, f)| {
|
||||
let m = Metrics::from_dict(doc, f);
|
||||
pdfcraft_fonts::embedding::face_key(&m.base_font) == key && FontStyle::of(doc, f, &m) == style
|
||||
})
|
||||
.take(128),
|
||||
);
|
||||
candidates
|
||||
}
|
||||
|
||||
/// A font's weight and slant, for matching subsets and complete programs of one face: a Regular
|
||||
/// program never completes text that a descriptor marks bold, say.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
struct FontStyle {
|
||||
bold: bool,
|
||||
italic: bool,
|
||||
}
|
||||
|
||||
impl FontStyle {
|
||||
/// Bold from the name or the descriptor's weight ([`Metrics`]); italic from the name or a
|
||||
/// non-zero `/ItalicAngle`. The descriptor's Italic flag alone isn't trusted: exporters set
|
||||
/// it on upright faces (a THSarabunPSK subset with `/Flags 96` and `/ItalicAngle 0`).
|
||||
fn of(doc: &Document, font: &Dict, metrics: &Metrics) -> Self {
|
||||
let descendant =
|
||||
font.get(b"DescendantFonts").and_then(|d| doc.resolve(d).as_array().and_then(|a| a.first()).cloned()).and_then(|d| doc.dict(&d));
|
||||
let descriptor = descendant.as_ref().unwrap_or(font).get(b"FontDescriptor").and_then(|d| doc.dict(d));
|
||||
let angle = descriptor.as_ref().and_then(|d| d.get(b"ItalicAngle")).and_then(|a| doc.resolve(a).as_f64()).unwrap_or(0.0);
|
||||
let name = metrics.base_font.to_ascii_lowercase();
|
||||
Self { bold: metrics.bold, italic: name.contains("italic") || name.contains("oblique") || (angle.is_finite() && angle.abs() > 0.1) }
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` shaped by `font`'s embedded program with its own layout, when the program covers it and
|
||||
/// may be embedded again (`embed_mapping` refuses one that may not, so a later program is tried).
|
||||
fn shape_with_program(doc: &Document, font: &Dict, text: &str) -> Option<(Vec<u8>, pdfcraft_fonts::source_shaping::ShapedRun)> {
|
||||
let bytes = pdfcraft_fonts::pdf::embedded_sfnt(doc, font)?;
|
||||
if pdfcraft_fonts::embedding::validate(&bytes).is_err() || !pdfcraft_fonts::embedding::covers(&bytes, text) {
|
||||
return None;
|
||||
}
|
||||
let shaped = pdfcraft_fonts::source_shaping::shape(&bytes, text).ok()?;
|
||||
Some((bytes, shaped))
|
||||
}
|
||||
|
||||
/// Shape in the source or an explicitly registered matching full face. Never use interface fonts.
|
||||
fn shape_source(doc: &Document, fonts: &Dict, thai: &ThaiShaping, name: &str, text: &str) -> Result<Option<ShapedText>, EditError> {
|
||||
if thai.outlined.contains(name) {
|
||||
let shaped = shape_thai_row(text)?;
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::ThaiOutlines }));
|
||||
}
|
||||
if let Some(resource) = thai.program.get(name) {
|
||||
let font = fonts
|
||||
.get(resource.as_bytes())
|
||||
.and_then(|f| doc.dict(f))
|
||||
.ok_or_else(|| EditError::Invalid("the complete font resource is missing".into()))?;
|
||||
let family = Metrics::from_dict(doc, &font).base_font;
|
||||
let (bytes, shaped) = shape_with_program(doc, &font, text)
|
||||
.ok_or_else(|| EditError::Invalid(format!("{family} cannot shape this text with its own layout; choose a font that covers it")))?;
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } }));
|
||||
}
|
||||
if !pdfcraft_fonts::source_shaping::required(text) {
|
||||
return Ok(None);
|
||||
}
|
||||
let source =
|
||||
fonts.get(name.as_bytes()).and_then(|f| doc.dict(f)).ok_or_else(|| EditError::Invalid("the shaped text has no font resource".into()))?;
|
||||
let family = Metrics::from_dict(doc, &source).base_font;
|
||||
let mut candidates = vec![source];
|
||||
candidates.extend(
|
||||
fonts
|
||||
.iter()
|
||||
.filter_map(|(_, f)| doc.dict(f))
|
||||
.filter(|f| pdfcraft_fonts::embedding::face_key(&Metrics::from_dict(doc, f).base_font) == pdfcraft_fonts::embedding::face_key(&family)),
|
||||
);
|
||||
for font in candidates {
|
||||
if let Some(bytes) = pdfcraft_fonts::pdf::embedded_sfnt(doc, &font)
|
||||
&& pdfcraft_fonts::embedding::covers(&bytes, text)
|
||||
&& let Ok(shaped) = pdfcraft_fonts::source_shaping::shape(&bytes, text)
|
||||
{
|
||||
return Ok(Some((bytes, shaped, family)));
|
||||
// In the face's existing codes first; what they can't show, a complete program of the face
|
||||
// may (`legacy_thai_font` already gave the span to one that can).
|
||||
let in_place = (family.rsplit('+').next().unwrap_or(&family).starts_with("THSarabunPSK")
|
||||
&& text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f))
|
||||
&& has_thai_source_face(&thai.legacy, &pdfcraft_fonts::embedding::face_key(&family)))
|
||||
.then(|| shape_existing_thai(&thai.legacy, name, text));
|
||||
let in_place = match in_place {
|
||||
Some(Ok(shaped)) => return Ok(Some(shaped)),
|
||||
Some(Err(error)) => Some(error),
|
||||
None => None,
|
||||
};
|
||||
for (_, font) in program_candidates(doc, fonts, name, source, &family) {
|
||||
if let Some((bytes, shaped)) = shape_with_program(doc, &font, text) {
|
||||
return Ok(Some(ShapedText { run: shaped, font: ShapedFont::Program { bytes, family } }));
|
||||
}
|
||||
}
|
||||
Err(EditError::Invalid("complex text needs the complete source TrueType/OpenType face; load that font before editing".into()))
|
||||
if let Some(error) = in_place {
|
||||
return Err(error);
|
||||
}
|
||||
let fallback = pdfcraft_fonts::document_thai_font()
|
||||
.filter(|_| text.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)))
|
||||
.map(|face| format!("; or select {} in the document-font menu to replace it", face.family))
|
||||
.unwrap_or_default();
|
||||
Err(EditError::Invalid(format!(
|
||||
"complex text in {} needs its complete source TrueType/OpenType font; use Load font to keep it{fallback}",
|
||||
family.rsplit('+').next().unwrap_or(&family)
|
||||
)))
|
||||
}
|
||||
|
||||
/// For each output row (a range of the draft's characters), whether it is exactly the source row
|
||||
@@ -889,10 +1510,12 @@ pub(crate) fn rewrite_styled_block(
|
||||
max_glyphs,
|
||||
}
|
||||
.prepare()?;
|
||||
let thai = prepared.thai;
|
||||
let chars = prepared.values;
|
||||
let substituted = prepared.substituted;
|
||||
let phrase_size = block.phrase_size();
|
||||
let size_of = |source: &TextLine, index: usize| {
|
||||
overrides.get(&index).and_then(|r| r.size).or(style.size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
|
||||
overrides.get(&index).and_then(|r| r.size).or(style.size).or(phrase_size).unwrap_or(source.size).max(0.1) / source.origin.k.max(1e-6)
|
||||
};
|
||||
// A source run may carry TJ kerning/justification that is absent from its text state.
|
||||
// Keep its measured advance when rebuilding the run instead of making it wrap on entry.
|
||||
@@ -910,7 +1533,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
spaces.borrow_mut().insert(name.to_owned(), w);
|
||||
w
|
||||
};
|
||||
let calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
|
||||
let mut calibration = calibration_factors(lines, &metrics, style, &overrides, space_of);
|
||||
fit_to_phrase(&mut calibration, lines, phrase_size);
|
||||
let advance = |item: &(char, usize, String)| -> f64 {
|
||||
let source = lines.get(item.1).unwrap_or(first);
|
||||
let Some(m) = metrics.get(&item.2) else { return 0.0 };
|
||||
@@ -939,7 +1563,8 @@ pub(crate) fn rewrite_styled_block(
|
||||
}
|
||||
let segment = items.get(at..end).unwrap_or_default();
|
||||
let string: String = segment.iter().map(|v| v.0).collect();
|
||||
if let Some((_, shaped, _)) = shape_source(doc, &fonts, &item.2, &string)? {
|
||||
if let Some(shaped) = shape_source(doc, &fonts, &thai, &item.2, &string)? {
|
||||
let shaped = &shaped.run;
|
||||
let source = lines.get(item.1).unwrap_or(first);
|
||||
let scale =
|
||||
overrides.get(&item.1).and_then(|r| r.scale).or(style.scale).map_or(source.origin.state.scale, |s| (s / 100.0).clamp(0.1, 10.0));
|
||||
@@ -990,7 +1615,7 @@ pub(crate) fn rewrite_styled_block(
|
||||
let at = if split { start } else { o.bt_op };
|
||||
validate_contextual_rows(&rows, &kept)?;
|
||||
let measured: Vec<_> = rows.iter().map(|row| measure(row)).collect::<Result<_, _>>()?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &rows, &kept, advance)?;
|
||||
let shaped = prepare_shaped_rows(doc, &fonts, &thai, &rows, &kept, advance)?;
|
||||
let ShapedRows { runs: shaped_runs, advances: segment_advances } = shaped;
|
||||
let glyph_resources = embed_shaped_resources(doc, &mut fonts, &shaped_runs)?;
|
||||
let n = pdfcraft_content::num;
|
||||
@@ -1100,67 +1725,19 @@ pub(crate) fn rewrite_styled_block(
|
||||
// drawn in: a `gs` has no inverse, so one run's ExtGState would stay for the next.
|
||||
block_ops.push(Op::new("q", vec![]));
|
||||
block_ops.extend(state.paint_ops(drawn_from, run_ctm));
|
||||
if let Some((_, shaped, _)) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resource = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let mut properties = Dict::new();
|
||||
properties.set(b"ActualText".to_vec(), Object::String(PdfString::text(&string)));
|
||||
block_ops.push(Op::new("BDC", vec![Object::name("Span"), Object::Dict(properties)]));
|
||||
state.font = Some((resource.clone(), size_of(source, item.1)));
|
||||
// One text object for the run: each glyph is placed where shaping put it by a TJ
|
||||
// adjustment from where the glyph before it ends (its advance in the embedded
|
||||
// font's widths), and raised by the text rise for a mark set above or below
|
||||
// (ISO 32000-1 §9.4.3, §9.3.7). Font-program codes are separate from Unicode;
|
||||
// ToUnicode and ActualText keep the logical text.
|
||||
let size = size_of(source, item.1);
|
||||
let per_unit = 1000.0 / size;
|
||||
if !per_unit.is_finite() {
|
||||
return Err(EditError::Invalid("the shaped text has no size".into()));
|
||||
}
|
||||
let shown = fonts
|
||||
.get(resource.as_bytes())
|
||||
.and_then(|f| doc.resolve(f).as_dict().cloned())
|
||||
.map(|d| Metrics::from_dict(doc, &d))
|
||||
.ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
// Spacing is part of each glyph's position, so the run itself has none.
|
||||
let (char_spacing, word_spacing, rise) = (state.char_spacing, state.word_spacing, state.rise);
|
||||
state.char_spacing = 0.0;
|
||||
state.word_spacing = 0.0;
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
block_ops.push(Op::new("Tm", tm.iter().map(|v| n(*v)).collect()));
|
||||
// `pen`: where the next glyph would start, in thousandths of the font size.
|
||||
let (mut pen, mut extra, mut raised) = (0.0, 0.0, rise);
|
||||
let mut shown_glyphs: Vec<Object> = Vec::new();
|
||||
for (i, glyph) in shaped.glyphs.iter().enumerate() {
|
||||
let target = (glyph.position[0] * size + extra) * per_unit;
|
||||
let glyph_rise = rise + glyph.position[1] * size;
|
||||
if (glyph_rise - raised).abs() > 1e-9 || shown_glyphs.len() >= 4096 {
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(std::mem::take(&mut shown_glyphs))]));
|
||||
}
|
||||
if (glyph_rise - raised).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(glyph_rise)]));
|
||||
raised = glyph_rise;
|
||||
}
|
||||
}
|
||||
if (target - pen).abs() > 1e-6 {
|
||||
shown_glyphs.push(n(pen - target));
|
||||
}
|
||||
let code = u16::try_from(i + 1).map_err(|_| EditError::Invalid("shaped glyph limit".into()))?;
|
||||
shown_glyphs.push(Object::String(PdfString::literal(code.to_be_bytes().to_vec())));
|
||||
pen = target + shown.width(u32::from(code)) * 1000.0;
|
||||
extra += char_spacing * glyph.text.chars().count() as f64 + word_spacing * glyph.text.matches(' ').count() as f64;
|
||||
}
|
||||
if !shown_glyphs.is_empty() {
|
||||
block_ops.push(Op::new("TJ", vec![Object::Array(shown_glyphs)]));
|
||||
}
|
||||
if (raised - rise).abs() > 1e-9 {
|
||||
block_ops.push(Op::new("Ts", vec![n(rise)]));
|
||||
}
|
||||
block_ops.push(Op::new("ET", vec![]));
|
||||
(state.char_spacing, state.word_spacing) = (char_spacing, word_spacing);
|
||||
block_ops.push(Op::new("EMC", vec![]));
|
||||
segment_width = (shaped.advance * size + extra) * state.scale * source.origin.k;
|
||||
if let Some(shaped) = shaped_runs.get(&(row_index, pos)) {
|
||||
let resources = glyph_resources.get(&(row_index, pos)).ok_or_else(|| EditError::Invalid("shaped font is missing".into()))?;
|
||||
let drawing = ShapedRunDrawing {
|
||||
doc,
|
||||
fonts: &fonts,
|
||||
shaped: &shaped.run,
|
||||
resources,
|
||||
text: &string,
|
||||
matrix: tm,
|
||||
size: size_of(source, item.1),
|
||||
thai: string.chars().any(|c| matches!(u32::from(c), 0x0e00..=0x0e7f)),
|
||||
};
|
||||
segment_width = drawing.draw(&mut block_ops, &mut state)? * state.scale * source.origin.k;
|
||||
} else {
|
||||
block_ops.push(Op::new("BT", vec![]));
|
||||
block_ops.extend(state.ops());
|
||||
|
||||
+232
-79
@@ -13,27 +13,32 @@
|
||||
//! a blend mode other than Normal inside, or a group drawn translucent, blended or under a soft
|
||||
//! mask, or a group with a blending colour space (`/CS`) other than the page's: its contents
|
||||
//! composite in that space and are then converted (ISO 32000-2 §11.6.6), inline they would
|
||||
//! composite in the page's. Those are refused rather than changed, as is a form whose
|
||||
//! `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` the page doesn't share (it remaps the form's
|
||||
//! device colours, §8.6.5.6, and inline the page's resources apply), tagged form content (its
|
||||
//! structure link would be lost) and content whose marked content or text objects don't balance
|
||||
//! (inlined, they would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//! composite in the page's. Those are refused rather than changed, as is a form with resources
|
||||
//! of its own whose `/DefaultGray`, `/DefaultRGB` or `/DefaultCMYK` differs from the page's, one
|
||||
//! having it and the other not included (it remaps the device colours painted with it,
|
||||
//! §8.6.5.6, and inline the page's resources apply), tagged form content (its structure link
|
||||
//! would be lost) and content whose marked content or text objects don't balance (inlined, they
|
||||
//! would change the scope of the page's own). A form in a layer (`/OC`) keeps its membership as `/OC … BDC … EMC`.
|
||||
//!
|
||||
//! A pattern's matrix maps pattern space to the default space of the content stream that uses it
|
||||
//! (ISO 32000-2 §8.7.2): inside the form that is the form's space at the `Do`, on the page it is
|
||||
//! the page's. So each pattern the form selects with `scn`/`SCN` is drawn from a copy whose
|
||||
//! `/Matrix` also carries the form's placement; one that can't be copied that way is refused.
|
||||
//!
|
||||
//! Edit text's preparation of grouped text (`text::forms`) opens each placement with this same
|
||||
//! code, and decides what to open from what [`Refusal`] and [`Ungrouped::shows_text`] say.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use pdfcraft_content::{Matrix, Op, parse};
|
||||
use pdfcraft_cos::{Dict, Document, Object};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object};
|
||||
|
||||
use crate::EditError;
|
||||
|
||||
/// The largest form content ungrouped (decoded bytes): the page's own content cap is far above
|
||||
/// any real form, and a bomb must not be copied into the page.
|
||||
const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
/// any real form, and a bomb must not be copied into the page. Ungroup reads the form and what it
|
||||
/// draws within one [`DecodeBudget`] of this size.
|
||||
pub(crate) const MAX_FORM_BYTES: usize = 64 << 20;
|
||||
|
||||
/// Resource categories a content stream refers to by name (ISO 32000-2 §7.8.3, Table 34).
|
||||
const CATEGORIES: [&[u8]; 7] = [b"Font", b"XObject", b"ExtGState", b"ColorSpace", b"Pattern", b"Shading", b"Properties"];
|
||||
@@ -45,28 +50,81 @@ const DEFAULT_SPACES: [&[u8]; 3] = [b"DefaultGray", b"DefaultRGB", b"DefaultCMYK
|
||||
/// The most distinct patterns one ungrouped form may select; each becomes a new object.
|
||||
const MAX_MOVED_PATTERNS: usize = 256;
|
||||
|
||||
/// The most entries of one resource category merged: a form or page with more is refused.
|
||||
const MAX_RESOURCES: usize = 4096;
|
||||
|
||||
/// The deepest `q` nesting a form's content may have.
|
||||
const MAX_SAVES: usize = 4096;
|
||||
|
||||
/// A name map for one resource category: form name → name in the page's resources.
|
||||
type Renames = Vec<(Vec<u8>, Vec<u8>)>;
|
||||
|
||||
/// What [`ungroup_ops`] gives back: the ops that draw the form inline, the page resources with
|
||||
/// the form's merged in, and the pattern copies to add to the document, each under the name in
|
||||
/// the merged `/Pattern` dictionary that the ops already use.
|
||||
pub(crate) type Ungrouped = (Vec<Op>, Dict, Vec<(Vec<u8>, Object)>);
|
||||
/// What [`ungroup_ops`] gives back.
|
||||
pub(crate) struct Ungrouped {
|
||||
/// The ops that draw the form inline.
|
||||
pub(crate) ops: Vec<Op>,
|
||||
/// The page resources with the form's merged in.
|
||||
pub(crate) resources: Dict,
|
||||
/// The pattern copies to add to the document, each under the name in the merged `/Pattern`
|
||||
/// dictionary that the ops already use.
|
||||
pub(crate) patterns: Vec<(Vec<u8>, Object)>,
|
||||
/// Whether the form shows text, in its own content or in a Form XObject it draws (at any
|
||||
/// depth [`MAX_NESTING`] allows). Text inside a tiling pattern or Type 3 glyph doesn't count:
|
||||
/// inlining the form doesn't make it editable. `None` when something it draws can't be read,
|
||||
/// is nested too deep, draws itself, or is past the reads or the decode budget allowed.
|
||||
pub(crate) shows_text: Option<bool>,
|
||||
}
|
||||
|
||||
/// Ungroup form `name` from the page's `resources`. `translucent` says whether the graphics state
|
||||
/// at the `Do` is translucent, blended or soft-masked, which a transparency group composites as a
|
||||
/// whole. `page_space` is the page group's blending colour space ([`page_blend_space`]).
|
||||
/// `placement` maps the form's space to the page's default space at the `Do` (its `/Matrix`,
|
||||
/// then the CTM there).
|
||||
/// Why [`ungroup_ops`] left a placement grouped.
|
||||
pub(crate) struct Refusal {
|
||||
pub(crate) error: EditError,
|
||||
/// The form itself is the reason (malformed, tagged, a knockout group, and so on), so every
|
||||
/// placement of it is refused the same way. Otherwise something about this placement or the
|
||||
/// page so far is: its translucency or matrix, the resources it would merge into (which grow
|
||||
/// as other groups are prepared), or the decode budget left.
|
||||
pub(crate) intrinsic: bool,
|
||||
/// The form's resources need renaming where it is drawn, and it draws something that would
|
||||
/// look them up by their old names. Names merged in for other forms can make the renaming
|
||||
/// unnecessary, which [`merges_without_renaming`] checks without reading the content.
|
||||
pub(crate) renaming: bool,
|
||||
}
|
||||
|
||||
/// Ungroup the form drawn by the `Do` at `page_ops[at]` (the page's streams parsed as one, the
|
||||
/// indexes `page_images` gives) from the page's `resources`. `page_space` is the page group's
|
||||
/// blending colour space ([`page_blend_space`]). `placement` maps the form's space to the page's
|
||||
/// default space at the `Do` (its `/Matrix`, then the CTM there). The form, and the forms and
|
||||
/// patterns it draws, decode within `budget`.
|
||||
pub(crate) fn ungroup_ops(
|
||||
doc: &Document,
|
||||
resources: &Dict,
|
||||
name: &[u8],
|
||||
translucent: bool,
|
||||
page_ops: &[Op],
|
||||
at: usize,
|
||||
page_space: Option<&Object>,
|
||||
placement: Matrix,
|
||||
) -> Result<Ungrouped, EditError> {
|
||||
let refuse = |why: &str| EditError::Invalid(format!("this artwork can't be ungrouped: {why}"));
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Result<Ungrouped, Refusal> {
|
||||
let refusal = |why: &str, intrinsic: bool| Refusal {
|
||||
error: EditError::Invalid(format!("this artwork can't be ungrouped: {why}")),
|
||||
intrinsic,
|
||||
renaming: false,
|
||||
};
|
||||
// Something about this placement or the page so far is the reason.
|
||||
let refuse = |why: &str| refusal(why, false);
|
||||
// The form itself is the reason: every placement of it is refused the same way.
|
||||
let invalid = |why: &str| refusal(why, true);
|
||||
let op = page_ops.get(at).filter(|op| op.is("Do")).ok_or_else(|| refuse("it is missing"))?;
|
||||
// The renderer draws a `Do`'s last operand, and the placement was read for its first
|
||||
// (`page_images`): with more than one they can be different forms.
|
||||
if op.operands.len() != 1 {
|
||||
return Err(refuse("it is drawn with an operand too many"));
|
||||
}
|
||||
let name = op.name(0).ok_or_else(|| refuse("it is missing"))?;
|
||||
let (translucent, open) = state_at(doc, resources, page_ops, at);
|
||||
// Inline, its clip would take in the page's unfinished path and its text objects would be
|
||||
// nested in the page's.
|
||||
if open {
|
||||
return Err(refuse("it is drawn inside an unfinished path or text object"));
|
||||
}
|
||||
let xobjects = resources.get(b"XObject").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).ok_or_else(|| refuse("it is missing"))?;
|
||||
let form = xobjects.get(name).ok_or_else(|| refuse("it is missing"))?;
|
||||
let Object::Stream(s) = &*doc.resolve(form) else { return Err(refuse("it is not a Form XObject")) };
|
||||
@@ -75,29 +133,35 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let numbers = |key: &[u8], n: usize| -> Option<Vec<f64>> {
|
||||
let a = s.dict.get(key).map(|o| doc.resolve(o))?;
|
||||
let v: Vec<f64> = a.as_array()?.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
let a = a.as_array()?;
|
||||
// Every entry must be a number: a short array padded out by a non-number is unreadable.
|
||||
let v: Vec<f64> = a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect();
|
||||
(a.len() == n && v.len() == n && v.iter().all(|x| x.is_finite())).then_some(v)
|
||||
};
|
||||
let bbox = numbers(b"BBox", 4).ok_or_else(|| refuse("its bounding box is missing or unreadable"))?;
|
||||
let bbox = numbers(b"BBox", 4).and_then(|b| <[f64; 4]>::try_from(b).ok()).ok_or_else(|| invalid("its bounding box is missing or unreadable"))?;
|
||||
let matrix = match s.dict.get(b"Matrix") {
|
||||
None => None,
|
||||
Some(_) => Some(Matrix(
|
||||
<[f64; 6]>::try_from(numbers(b"Matrix", 6).ok_or_else(|| refuse("its matrix is unreadable"))?)
|
||||
.map_err(|_| refuse("its matrix is unreadable"))?,
|
||||
)),
|
||||
Some(_) => Some(Matrix(numbers(b"Matrix", 6).and_then(|m| <[f64; 6]>::try_from(m).ok()).ok_or_else(|| invalid("its matrix is unreadable"))?)),
|
||||
};
|
||||
let own = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
if s.dict.get(b"Resources").is_some() && own.is_none() {
|
||||
return Err(invalid("its resources can't be read"));
|
||||
}
|
||||
// A form without its own resources uses the ones it is drawn with (PDF 1.1).
|
||||
let form_res = own.clone().unwrap_or_else(|| resources.clone());
|
||||
|
||||
// A tagged form's content is a structure element's: drawn inline it would lose that link.
|
||||
if s.dict.get(b"StructParent").is_some() || s.dict.get(b"StructParents").is_some() {
|
||||
return Err(refuse("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
return Err(invalid("it is tagged content (Tags panel); edit its text in a copy without tags"));
|
||||
}
|
||||
let group = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
let group_dict = s.dict.get(b"Group").map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned());
|
||||
if s.dict.get(b"Group").is_some() && group_dict.is_none() {
|
||||
return Err(invalid("its group dictionary can't be read"));
|
||||
}
|
||||
let group = group_dict.filter(|g| is_name(doc, g, b"S", b"Transparency"));
|
||||
if let Some(group) = &group {
|
||||
if group.get(b"K").is_some_and(|k| matches!(*doc.resolve(k), Object::Bool(true))) {
|
||||
return Err(refuse("it is a knockout transparency group"));
|
||||
return Err(invalid("it is a knockout transparency group"));
|
||||
}
|
||||
if translucent {
|
||||
return Err(refuse("it is a transparency group drawn translucent, blended or under a soft mask"));
|
||||
@@ -107,32 +171,39 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(cs) = group.get(b"CS")
|
||||
&& !same_blend_space(doc, cs, page_space)
|
||||
{
|
||||
return Err(refuse("it is a transparency group that blends in a colour space of its own"));
|
||||
return Err(invalid("it is a transparency group that blends in a colour space of its own"));
|
||||
}
|
||||
}
|
||||
|
||||
// Strictly: damaged data must not be copied in as a shorter form.
|
||||
let data = s.decoded_strict_within(MAX_FORM_BYTES).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let data = budget.decode_strict(doc, s).map_err(|e| refuse(&format!("its content can't be read ({e})")))?;
|
||||
let parsed = parse(&data);
|
||||
if parsed.skipped > 0 {
|
||||
return Err(refuse("its content has parts that can't be read"));
|
||||
return Err(invalid("its content has parts that can't be read"));
|
||||
}
|
||||
let mut ops = parsed.ops;
|
||||
if group.is_some() {
|
||||
// The form itself is being read: drawing itself again counts as blending.
|
||||
let mut walk = BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0 };
|
||||
if blends_inside(doc, &form_res, &ops, 0, &mut walk) {
|
||||
return Err(refuse("it is a transparency group with blend modes inside"));
|
||||
}
|
||||
// What it draws is read for text whether it is a group or not, and for blend modes only in a
|
||||
// group: without one, blending inside draws the same inline. The form itself is being read,
|
||||
// so drawing itself again is content that can't be checked.
|
||||
let mut walk =
|
||||
BlendWalk { path: form.as_ref().into_iter().collect(), known: HashMap::new(), reads: 0, budget, text: false, group: group.is_some() };
|
||||
let unchecked = blends_inside(doc, &form_res, &ops, 0, &mut walk);
|
||||
if unchecked && group.is_some() {
|
||||
return Err(refuse("it is a transparency group with blend modes inside, or content inside that can't be checked"));
|
||||
}
|
||||
let shows_text = (!unchecked).then_some(walk.text);
|
||||
|
||||
// A form's default colour space remaps its device colours (ISO 32000-2 §8.6.5.6); inline,
|
||||
// the page's resources are the current ones (§7.8.3).
|
||||
if own.is_some()
|
||||
&& let Some(key) = unshared_default_space(doc, &form_res, resources)
|
||||
&& let Some((key, form_has)) = unshared_default_space(doc, &form_res, resources)
|
||||
{
|
||||
let key = String::from_utf8_lossy(key);
|
||||
return Err(refuse(&format!("it remaps its device colours with a /{key} the page doesn't share")));
|
||||
return Err(refuse(&if form_has {
|
||||
format!("it remaps its device colours with a /{key} the page doesn't share")
|
||||
} else {
|
||||
format!("the page's /{key} would remap its device colours, which it draws as they are")
|
||||
}));
|
||||
}
|
||||
|
||||
// Merge the form's resources into a copy of the page's, renaming what clashes.
|
||||
@@ -141,7 +212,13 @@ pub(crate) fn ungroup_ops(
|
||||
if own.is_some() {
|
||||
for cat in CATEGORIES {
|
||||
let Some(theirs) = form_res.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()) else { continue };
|
||||
if theirs.len() > MAX_RESOURCES {
|
||||
return Err(invalid("too many resources"));
|
||||
}
|
||||
let mut mine = merged.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
if mine.len() > MAX_RESOURCES {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
let mut map = Renames::new();
|
||||
let mut n = 0u32;
|
||||
for (key, value) in theirs.iter() {
|
||||
@@ -163,6 +240,11 @@ pub(crate) fn ungroup_ops(
|
||||
mine.set(fresh.clone(), value.clone());
|
||||
map.push((key.to_vec(), fresh));
|
||||
}
|
||||
// Counted after the merge: a page taken past the limit would refuse every later form,
|
||||
// even one whose resources are all the page's already.
|
||||
if mine.len() > MAX_RESOURCES {
|
||||
return Err(refuse("too many resources"));
|
||||
}
|
||||
merged.set(cat.to_vec(), Object::Dict(mine));
|
||||
if !map.is_empty() {
|
||||
renames.push((cat, map));
|
||||
@@ -177,7 +259,7 @@ pub(crate) fn ungroup_ops(
|
||||
// would find the page's, which differ wherever a name was renamed (every form resource that
|
||||
// isn't already the page's, the nested item's own entry included).
|
||||
if !renames.is_empty() && ops.iter().any(|op| inherits_resources(doc, &form_res, op)) {
|
||||
return Err(refuse("it draws a nested form, Type 3 font or pattern that uses its resources"));
|
||||
return Err(Refusal { renaming: true, ..refuse("it draws a nested form, Type 3 font or pattern that uses its resources") });
|
||||
}
|
||||
let mut depth = 0usize;
|
||||
// Marked content and text objects must balance within the form's own stream: an extra EMC
|
||||
@@ -187,19 +269,20 @@ pub(crate) fn ungroup_ops(
|
||||
for op in &mut ops {
|
||||
match op.op.as_slice() {
|
||||
b"BDC" | b"BMC" => marked = marked.saturating_add(1),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| refuse("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(refuse("its content begins a text object inside another")),
|
||||
b"EMC" => marked = marked.checked_sub(1).ok_or_else(|| invalid("its content ends marked content it didn't begin"))?,
|
||||
b"BT" if in_text => return Err(invalid("its content begins a text object inside another")),
|
||||
b"BT" => in_text = true,
|
||||
b"ET" if !in_text => return Err(refuse("its content ends a text object it didn't begin")),
|
||||
b"ET" if !in_text => return Err(invalid("its content ends a text object it didn't begin")),
|
||||
b"ET" => in_text = false,
|
||||
_ => {}
|
||||
}
|
||||
match op.op.as_slice() {
|
||||
b"q" => depth = depth.saturating_add(1),
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| refuse("its content restores more graphics states than it saves"))?,
|
||||
b"q" if depth >= MAX_SAVES => return Err(invalid("its graphics states are nested too deeply")),
|
||||
b"q" => depth += 1,
|
||||
b"Q" => depth = depth.checked_sub(1).ok_or_else(|| invalid("its content restores more graphics states than it saves"))?,
|
||||
_ => {}
|
||||
}
|
||||
let slot = resource_operand(op).map_err(|Unreadable| refuse("its content selects a resource with operands that can't be read"))?;
|
||||
let slot = resource_operand(op).map_err(|Unreadable| invalid("its content selects a resource with operands that can't be read"))?;
|
||||
if let Some((cat, i)) = slot
|
||||
&& let Some(operand) = op.operands.get_mut(i)
|
||||
&& let Object::Name(n) = operand
|
||||
@@ -219,13 +302,15 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
|
||||
if marked > 0 || in_text {
|
||||
return Err(refuse("its content leaves marked content or a text object open"));
|
||||
return Err(invalid("its content leaves marked content or a text object open"));
|
||||
}
|
||||
|
||||
// Patterns keep their place relative to the shapes they fill: each one the form selects is
|
||||
// drawn from a copy that also carries the placement. Nested forms and patterns inside
|
||||
// patterns keep their own parents, so only this stream's selections change.
|
||||
let mut patterns: Vec<(Vec<u8>, Object)> = Vec::new();
|
||||
// What the copies of tiling patterns may add in all.
|
||||
let mut pattern_bytes = MAX_FORM_BYTES;
|
||||
if placement != Matrix::IDENTITY {
|
||||
let mut table = merged.get(b"Pattern").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let mut moved: Renames = Vec::new();
|
||||
@@ -243,6 +328,9 @@ pub(crate) fn ungroup_ops(
|
||||
}
|
||||
let copy =
|
||||
moved_pattern(doc, &entry, placement).ok_or_else(|| refuse("it paints with a pattern whose position can't be kept"))?;
|
||||
if let Object::Stream(s) = © {
|
||||
pattern_bytes = pattern_bytes.checked_sub(s.raw.len()).ok_or_else(|| refuse("its pattern copies would be too large"))?;
|
||||
}
|
||||
let fresh = loop {
|
||||
n = n.saturating_add(1);
|
||||
let candidate = format!("PCUgPat{n}").into_bytes();
|
||||
@@ -300,28 +388,59 @@ pub(crate) fn ungroup_ops(
|
||||
if let Some(m) = matrix {
|
||||
out.push(Op::new("cm", m.0.iter().copied().map(num).collect()));
|
||||
}
|
||||
let (x0, y0, x1, y1) = (bbox[0].min(bbox[2]), bbox[1].min(bbox[3]), bbox[0].max(bbox[2]), bbox[1].max(bbox[3]));
|
||||
let [a, b, c, d] = bbox;
|
||||
let (x0, y0, x1, y1) = (a.min(c), b.min(d), a.max(c), b.max(d));
|
||||
out.push(Op::new("re", vec![num(x0), num(y0), num(x1 - x0), num(y1 - y0)]));
|
||||
out.push(Op::new("W", Vec::new()));
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
out.extend(ops);
|
||||
// A path the form leaves unfinished ends with it, not in the page's content after it.
|
||||
out.push(Op::new("n", Vec::new()));
|
||||
// A form ends with the state it was drawn with (the Do saves and restores it).
|
||||
out.extend(std::iter::repeat_with(|| Op::new("Q", Vec::new())).take(depth));
|
||||
out.push(Op::new("Q", Vec::new()));
|
||||
if layer.is_some() {
|
||||
out.push(Op::new("EMC", Vec::new()));
|
||||
}
|
||||
Ok((out, merged, patterns))
|
||||
Ok(Ungrouped { ops: out, resources: merged, patterns, shows_text })
|
||||
}
|
||||
|
||||
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` in the form's `/ColorSpace`
|
||||
/// resources that the page's don't have with an equal value (the same object, or the same value
|
||||
/// resolved). A default colour space remaps the device colours painted with the resource
|
||||
/// dictionary it is in (ISO 32000-2 §8.6.5.6, §7.8.3), so inline only the page's would apply.
|
||||
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<&'static [u8]> {
|
||||
/// Whether form `name` in `resources` would merge into them without a rename: it has resources
|
||||
/// of its own, and each of their entries is already in `resources` under the same name, as
|
||||
/// [`ungroup_ops`] compares them. Only dictionaries are read, never the form's content.
|
||||
pub(crate) fn merges_without_renaming(doc: &Document, resources: &Dict, name: &[u8]) -> bool {
|
||||
let category = |d: &Dict, cat: &[u8]| d.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned());
|
||||
let Some(form) = category(resources, b"XObject").and_then(|x| x.get(name).cloned()) else { return false };
|
||||
let Object::Stream(s) = &*doc.resolve(&form) else { return false };
|
||||
let Some(own) = s.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned()) else { return false };
|
||||
CATEGORIES.into_iter().all(|cat| {
|
||||
let Some(theirs) = category(&own, cat) else { return true };
|
||||
let mine = category(resources, cat).unwrap_or_default();
|
||||
// Past these sizes `ungroup_ops` refuses the form anyway. Default colour spaces are
|
||||
// never renamed (`ungroup_ops` checks them separately).
|
||||
theirs.len() <= MAX_RESOURCES
|
||||
&& mine.len() <= MAX_RESOURCES
|
||||
&& theirs.iter().all(|(key, value)| mine.get(key) == Some(value) || (cat == b"ColorSpace" && DEFAULT_SPACES.contains(&&key[..])))
|
||||
})
|
||||
}
|
||||
|
||||
/// The first of `/DefaultGray`, `/DefaultRGB` and `/DefaultCMYK` that the form's and the page's
|
||||
/// `/ColorSpace` resources don't share with an equal value (the same object, or the same value
|
||||
/// resolved), and whether the form has it (else only the page does). A default colour space
|
||||
/// remaps the device colours painted with the resource dictionary it is in (ISO 32000-2
|
||||
/// §8.6.5.6, §7.8.3), so inline only the page's would apply: the form's would no longer remap
|
||||
/// its colours, and the page's would start to.
|
||||
fn unshared_default_space(doc: &Document, form: &Dict, page: &Dict) -> Option<(&'static [u8], bool)> {
|
||||
let spaces = |res: &Dict| res.get(b"ColorSpace").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (theirs, mine) = (spaces(form), spaces(page));
|
||||
DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some_and(|v| !mine.get(key).is_some_and(|p| *doc.resolve(p) == *doc.resolve(v))))
|
||||
let differs = |key: &[u8]| match (theirs.get(key), mine.get(key)) {
|
||||
(None, None) => false,
|
||||
(Some(v), Some(p)) => *doc.resolve(p) != *doc.resolve(v),
|
||||
_ => true,
|
||||
};
|
||||
// The form's own entries are named first.
|
||||
let form_has = DEFAULT_SPACES.into_iter().find(|key| theirs.get(key).is_some() && differs(key)).map(|key| (key, true));
|
||||
form_has.or_else(|| DEFAULT_SPACES.into_iter().find(|key| differs(key)).map(|key| (key, false)))
|
||||
}
|
||||
|
||||
/// A copy of pattern `entry` whose `/Matrix` maps its space to the page's default space instead
|
||||
@@ -339,6 +458,10 @@ fn moved_pattern(doc: &Document, entry: &Object, placement: Matrix) -> Option<Ob
|
||||
Some(m) => {
|
||||
let m = doc.resolve(m);
|
||||
let nums: Vec<Object> = m.as_array()?.iter().map(|x| doc.resolve(x).as_ref().clone()).collect();
|
||||
// A matrix is six numbers: a longer array is unreadable, not cut short.
|
||||
if nums.len() != 6 {
|
||||
return None;
|
||||
}
|
||||
Matrix::from_operands(&nums)?
|
||||
}
|
||||
};
|
||||
@@ -362,12 +485,23 @@ const MAX_NESTING: usize = 8;
|
||||
const MAX_NESTED_READS: usize = 1024;
|
||||
|
||||
/// What [`blends_inside`] carries through its nested reads: the forms on the current path (a
|
||||
/// form drawing itself), the answer for each form with resources of its own (its answer doesn't
|
||||
/// depend on who draws it), and how many reads it has made.
|
||||
struct BlendWalk {
|
||||
/// form drawing itself), the answers for each form with resources of its own (whether it blends
|
||||
/// or can't be checked, and whether it shows text; neither depends on who draws it), how many
|
||||
/// reads it has made, the decode budget they share with the form itself, whether the content
|
||||
/// being walked shows text itself or through a Form XObject it draws, and whether blend modes
|
||||
/// count (they do in a group).
|
||||
struct BlendWalk<'a> {
|
||||
path: Vec<pdfcraft_cos::ObjRef>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, bool>,
|
||||
known: HashMap<pdfcraft_cos::ObjRef, (bool, bool)>,
|
||||
reads: usize,
|
||||
budget: &'a mut DecodeBudget,
|
||||
text: bool,
|
||||
group: bool,
|
||||
}
|
||||
|
||||
/// Whether `op` shows text (ISO 32000-2 §9.4.3, Table 107).
|
||||
fn shows_text(op: &Op) -> bool {
|
||||
matches!(op.op.as_slice(), b"Tj" | b"TJ" | b"'" | b"\"")
|
||||
}
|
||||
|
||||
/// An operator whose operands the renderer can't read as it expects, so it skips the operator.
|
||||
@@ -440,15 +574,22 @@ fn inherits_resources(doc: &Document, resources: &Dict, op: &Op) -> bool {
|
||||
drawn && dict.get(b"Resources").is_none()
|
||||
}
|
||||
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal: a `gs` they
|
||||
/// use, or one used by a form or pattern they draw, at any depth. A shading pattern blends through
|
||||
/// its own `/ExtGState`, a tiling pattern through its content. A nested form or pattern that can't
|
||||
/// be read, is nested too deep or draws itself counts as blending, so the caller refuses rather
|
||||
/// than guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk) -> bool {
|
||||
/// Whether `ops` (drawn with `resources`) blend with something other than Normal, which counts
|
||||
/// only in a group (`walk.group`): a `gs` they use, or one used by a form or pattern they draw, at
|
||||
/// any depth. A shading pattern blends through its own `/ExtGState`, a tiling pattern through its
|
||||
/// content. A nested form or pattern that can't be read, is nested too deep, draws itself or is
|
||||
/// past the reads or the budget allowed counts as blending, so the caller refuses rather than
|
||||
/// guesses. An operator the renderer skips ([`Unreadable`]) draws nothing and is skipped. Sets
|
||||
/// `walk.text` when `ops` show text, or a Form XObject they draw does; text in a tiling pattern's
|
||||
/// cell is not the page's text and is not counted.
|
||||
fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, walk: &mut BlendWalk<'_>) -> bool {
|
||||
let category = |cat: &[u8]| resources.get(cat).map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
let (states, xobjects, patterns) = (category(b"ExtGState"), category(b"XObject"), category(b"Pattern"));
|
||||
let state_blends = |g: Option<&Object>| g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
let group = walk.group;
|
||||
let state_blends = |g: Option<&Object>| group && g.map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()).is_some_and(|gs| blends(doc, &gs));
|
||||
if ops.iter().any(shows_text) {
|
||||
walk.text = true;
|
||||
}
|
||||
for op in ops {
|
||||
let Ok(Some((cat, i))) = resource_operand(op) else { continue };
|
||||
let Some(name) = op.name(i) else { continue };
|
||||
@@ -479,17 +620,19 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
_ => continue,
|
||||
};
|
||||
let r = o.as_ref();
|
||||
if let Some(known) = r.and_then(|r| walk.known.get(&r)) {
|
||||
if *known {
|
||||
let drawn_form = op.is("Do");
|
||||
if let Some((known, text)) = r.and_then(|r| walk.known.get(&r)).copied() {
|
||||
if known {
|
||||
return true;
|
||||
}
|
||||
walk.text |= drawn_form && text;
|
||||
continue;
|
||||
}
|
||||
if depth >= MAX_NESTING || r.is_some_and(|r| walk.path.contains(&r)) || walk.reads >= MAX_NESTED_READS {
|
||||
return true;
|
||||
}
|
||||
walk.reads += 1;
|
||||
let Ok(data) = nested.decoded_strict_within(MAX_FORM_BYTES) else { return true };
|
||||
let Ok(data) = walk.budget.decode_strict(doc, nested) else { return true };
|
||||
let own_resources = nested.dict.get(b"Resources").map(|r| doc.resolve(r)).and_then(|r| r.as_dict().cloned());
|
||||
let has_own = own_resources.is_some();
|
||||
let own = own_resources.unwrap_or_else(|| resources.clone());
|
||||
@@ -500,11 +643,14 @@ fn blends_inside(doc: &Document, resources: &Dict, ops: &[Op], depth: usize, wal
|
||||
if let Some(r) = r {
|
||||
walk.path.push(r);
|
||||
}
|
||||
let outer_text = std::mem::replace(&mut walk.text, false);
|
||||
let blends = blends_inside(doc, &own, &inner.ops, depth + 1, walk);
|
||||
let inner_text = walk.text;
|
||||
walk.text = outer_text || (drawn_form && inner_text);
|
||||
if let Some(r) = r {
|
||||
walk.path.retain(|x| *x != r);
|
||||
if has_own {
|
||||
walk.known.insert(r, blends);
|
||||
walk.known.insert(r, (blends, inner_text));
|
||||
}
|
||||
}
|
||||
if blends {
|
||||
@@ -552,18 +698,25 @@ fn blends(doc: &Document, gs: &Dict) -> bool {
|
||||
!first.is_some_and(|b| matches!(b.as_name(), Some(b"Normal" | b"Compatible")))
|
||||
}
|
||||
|
||||
/// Whether the graphics state is translucent, blended or soft-masked at op `at` of the page's
|
||||
/// streams parsed as one (the indexes `page_images` gives), following `q`/`Q` and `gs`. Each `gs`
|
||||
/// changes only the parameters it sets.
|
||||
pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> bool {
|
||||
/// The state at op `at` of the page's streams parsed as one: whether the graphics state is
|
||||
/// translucent, blended or soft-masked there, following `q`/`Q` and `gs` (each `gs` changes only
|
||||
/// the parameters it sets), which a transparency group composites as a whole; and whether a path
|
||||
/// or a text object is open there.
|
||||
fn state_at(doc: &Document, resources: &Dict, ops: &[Op], at: usize) -> (bool, bool) {
|
||||
let states = resources.get(b"ExtGState").map(|x| doc.resolve(x)).and_then(|x| x.as_dict().cloned()).unwrap_or_default();
|
||||
// Fill alpha below one, stroke alpha below one, a soft mask, a blend mode other than Normal.
|
||||
let mut stack: Vec<[bool; 4]> = Vec::new();
|
||||
let mut now = [false; 4];
|
||||
let (mut path, mut text) = (false, false);
|
||||
for op in ops.iter().take(at) {
|
||||
match op.op.as_slice() {
|
||||
b"m" | b"l" | b"c" | b"v" | b"y" | b"re" | b"h" | b"W" | b"W*" => path = true,
|
||||
b"S" | b"s" | b"f" | b"F" | b"f*" | b"B" | b"B*" | b"b" | b"b*" | b"n" => path = false,
|
||||
b"BT" => text = true,
|
||||
b"ET" => text = false,
|
||||
b"q" => stack.push(now),
|
||||
b"Q" => now = stack.pop().unwrap_or([false; 4]),
|
||||
// A Q with nothing to restore is ignored, as renderers ignore it.
|
||||
b"Q" => now = stack.pop().unwrap_or(now),
|
||||
b"gs" => {
|
||||
let Ok(Some((_, i))) = resource_operand(op) else { continue };
|
||||
let Some(gs) = op.name(i).and_then(|n| states.get(n)).map(|g| doc.resolve(g)).and_then(|g| g.as_dict().cloned()) else {
|
||||
@@ -586,5 +739,5 @@ pub(crate) fn translucent_at(doc: &Document, resources: &Dict, ops: &[Op], at: u
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
now.iter().any(|v| *v)
|
||||
(now.iter().any(|v| *v), path || text)
|
||||
}
|
||||
|
||||
@@ -333,11 +333,13 @@ impl crate::Document {
|
||||
let (pages, truncated_pages) =
|
||||
self.export_pages_checked(!matches!(format, OfficeFormat::Rtf | OfficeFormat::Markdown), MAX_EXPORT_IMAGE_BYTES);
|
||||
let title = self.info.title.clone().unwrap_or_else(|| self.name.trim_end_matches(".pdf").to_string());
|
||||
// Word and HTML name the document's language (`/Lang`), so spelling is checked in it.
|
||||
let language = self.initial_view().language;
|
||||
let bytes = match format {
|
||||
OfficeFormat::Docx => pdfcraft_export::docx(&pages, &title),
|
||||
OfficeFormat::Docx => pdfcraft_export::docx_with_language(&pages, &title, language.as_deref()),
|
||||
OfficeFormat::Pptx => pdfcraft_export::pptx(&pages, &title),
|
||||
OfficeFormat::Html => pdfcraft_export::html(&pages, &title).into_bytes(),
|
||||
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple(&pages, &title).into_bytes(),
|
||||
OfficeFormat::Html => pdfcraft_export::html_with_language(&pages, &title, language.as_deref()).into_bytes(),
|
||||
OfficeFormat::HtmlSimple => pdfcraft_export::html_simple_with_language(&pages, &title, language.as_deref()).into_bytes(),
|
||||
OfficeFormat::Rtf => pdfcraft_export::rtf(&pages).into_bytes(),
|
||||
OfficeFormat::Markdown => pdfcraft_export::markdown(&pages).into_bytes(),
|
||||
};
|
||||
|
||||
@@ -260,6 +260,15 @@ impl Document {
|
||||
pdfcraft_create::image_file(&editor.cos, img.object.ok_or("the image has no object")?)
|
||||
}
|
||||
|
||||
/// Edit text: whether grouped artwork `index` on `page` (0-based, as [`Self::page_images`]
|
||||
/// lists them) shows text that [`Edit::PrepareText`](crate::Edit::PrepareText) would make
|
||||
/// editable. Reads the page without changing it; `false` for a logo, a group that can't be
|
||||
/// opened or a page that can't be read.
|
||||
pub fn grouped_text_at(&self, page: usize, index: usize) -> bool {
|
||||
let Some(editor) = self.editor.as_ref() else { return false };
|
||||
crate::guard(|| pdfcraft_edit::text::forms::grouped_text_at(&editor.cos, page, index)).is_ok_and(|r| r.is_ok_and(|yes| yes))
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: the paragraphs on `page` (0-based).
|
||||
pub fn text_blocks(&self, page: usize) -> Vec<pdfcraft_edit::TextBlock> {
|
||||
self.editor.as_ref().and_then(|e| e.text_blocks(page).ok()).map(|b| b.as_ref().clone()).unwrap_or_default()
|
||||
|
||||
@@ -466,6 +466,10 @@ pub enum Edit {
|
||||
/// Formatting changes (font, size, colour, alignment); default keeps the paragraph's.
|
||||
style: pdfcraft_edit::BlockStyle,
|
||||
},
|
||||
/// Expose supported grouped source text on one page for direct editing.
|
||||
PrepareText {
|
||||
page: usize,
|
||||
},
|
||||
/// An explicit open-licensed full font program, completing subsets or selected by a user.
|
||||
RegisterTextFont {
|
||||
page: usize,
|
||||
|
||||
@@ -111,6 +111,7 @@ pub const EDIT_LABELS: &[&str] = &[
|
||||
"Delete document JavaScript",
|
||||
"Edit document JavaScript",
|
||||
"Edit text",
|
||||
"Prepare grouped text",
|
||||
"Load document font",
|
||||
"Move object",
|
||||
"Move objects",
|
||||
@@ -269,6 +270,7 @@ impl Edit {
|
||||
Edit::SetDocumentScript { script: None, .. } => Label::of("Delete document JavaScript"),
|
||||
Edit::SetDocumentScript { .. } => Label::of("Edit document JavaScript"),
|
||||
Edit::EditTextLine { .. } | Edit::EditTextBlock { .. } => Label::of("Edit text"),
|
||||
Edit::PrepareText { .. } => Label::of("Prepare grouped text"),
|
||||
Edit::RegisterTextFont { .. } => Label::of("Load document font"),
|
||||
Edit::MoveObjects { objects, .. } => plural("Move object", "Move objects", objects.len()),
|
||||
Edit::EditPageImage { change, .. } => Label::of(match change {
|
||||
|
||||
@@ -148,6 +148,7 @@ pub(crate) fn check_permission(edit: &Edit, p: &pdfcraft_cos::Permissions) -> Re
|
||||
| Edit::RemoveStandardId { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::PrepareText { .. }
|
||||
| Edit::RegisterTextFont { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::Flatten { .. }
|
||||
@@ -193,6 +194,9 @@ pub(crate) struct EditCtx {
|
||||
/// The overlay streams the edit's applied redactions added, which alone the renderer's check
|
||||
/// leaves out.
|
||||
pub(crate) redaction_overlays: Vec<pdfcraft_cos::ObjRef>,
|
||||
/// What [`Edit::PrepareText`] prepared, repaired and left grouped, for
|
||||
/// [`Session::prepare_text`](crate::Session::prepare_text).
|
||||
pub(crate) prepared: Option<pdfcraft_edit::text::forms::Preparation>,
|
||||
}
|
||||
|
||||
impl EditCtx {
|
||||
@@ -222,6 +226,7 @@ impl EditCtx {
|
||||
rng: getrandom::fill,
|
||||
renamed_fields: Vec::new(),
|
||||
redaction_overlays: Vec::new(),
|
||||
prepared: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +732,15 @@ pub(crate) fn run_edit(doc: &mut pdfcraft_cos::Document, edit: &Edit, cx: &mut E
|
||||
Edit::EditTextBlock { page, block, text, style } => {
|
||||
pdfcraft_edit::rewrite_block(doc, *page, *block, Some(text), style)?;
|
||||
}
|
||||
Edit::PrepareText { page } => {
|
||||
let result = pdfcraft_edit::text::forms::prepare(doc, *page)?;
|
||||
if result.expanded == 0 && result.repaired_fonts == 0 {
|
||||
return Err(EditError::Invalid(
|
||||
result.refused.first().cloned().unwrap_or_else(|| "this page has no supported grouped text to prepare".into()),
|
||||
));
|
||||
}
|
||||
cx.prepared = Some(result);
|
||||
}
|
||||
Edit::RegisterTextFont { page, bytes } => {
|
||||
pdfcraft_edit::text::register_text_font(doc, *page, bytes)?;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,7 @@ pub(crate) fn scope_of(edit: &Edit) -> Scope {
|
||||
Edit::MoveObjects { .. }
|
||||
| Edit::EditTextLine { .. }
|
||||
| Edit::EditTextBlock { .. }
|
||||
| Edit::PrepareText { .. }
|
||||
| Edit::EditPageImage { .. }
|
||||
| Edit::EnhanceScan { .. }
|
||||
| Edit::AddText { .. }
|
||||
|
||||
+382
-159
@@ -15,7 +15,8 @@ use pdfcraft_render::text::TextFilter;
|
||||
use pdfcraft_render::{PageInfo, PageRenderer, RenderConfig, RenderRequest, RequestKind};
|
||||
|
||||
pub use pdfcraft_ocr::{
|
||||
ConfidenceBand, LANGUAGES, MergeStrategy, Models, Ocr, OcrError, PlacedWord, Recognizer, band_for, is_suspect, low_confidence,
|
||||
ConfidenceBand, DETECTION_MODEL, LANGUAGES, Line, MergeStrategy, Models, Ocr, OcrError, OcrImage, PlacedWord, RECOGNITION_MODEL, Recognizer,
|
||||
StrategyOutcome, Word, band_for, is_suspect, lines_from_json, low_confidence,
|
||||
};
|
||||
|
||||
use crate::{DocId, Edit, EditError, Session};
|
||||
@@ -257,6 +258,23 @@ struct EngineCache {
|
||||
ocrs: Option<Arc<dyn Recognizer>>,
|
||||
}
|
||||
|
||||
/// The engines built so far (see [`recognizers`] and [`install`]).
|
||||
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
|
||||
|
||||
/// Share an ocrs recogniser loaded some other way than from the model files [`Models::find`]
|
||||
/// looks for: the web build downloads the models and loads them with [`Ocr::from_bytes`]. From
|
||||
/// then on [`recognizers`] hands it out and the engine counts as available.
|
||||
pub fn install(ocr: Ocr) -> Arc<dyn Recognizer> {
|
||||
let ocrs: Arc<dyn Recognizer> = Arc::new(pdfcraft_ocr::OcrsRecognizer::from_ocr(ocr));
|
||||
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).get_or_insert_with(|| EngineCache { ocrs: None }).ocrs = Some(ocrs.clone());
|
||||
ocrs
|
||||
}
|
||||
|
||||
/// Whether an ocrs recogniser has been loaded (from the model files, or by [`install`]).
|
||||
fn ocrs_loaded() -> bool {
|
||||
ENGINES.lock().unwrap_or_else(|e| e.into_inner()).as_ref().is_some_and(|c| c.ocrs.is_some())
|
||||
}
|
||||
|
||||
/// How long a negative engine probe (no program there) is remembered. A short TTL, not
|
||||
/// forever: a tesseract installed while the app runs is found by the next job, without a
|
||||
/// restart. An engine that was found is remembered for the session.
|
||||
@@ -408,10 +426,11 @@ fn models_installed_cached() -> bool {
|
||||
found
|
||||
}
|
||||
|
||||
/// The web build has no disk search worth caching.
|
||||
/// The web build has no model files: the models count as installed once [`install`] has
|
||||
/// loaded the ones it downloaded.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn models_installed_cached() -> bool {
|
||||
Models::find().is_some()
|
||||
ocrs_loaded()
|
||||
}
|
||||
|
||||
/// The program's `--version` line, remembered per program path for the session (`ocr_status`
|
||||
@@ -473,7 +492,7 @@ pub fn engines_status() -> Vec<EngineStatus> {
|
||||
/// takes a location, because an agent must not choose which program runs; the tools report the
|
||||
/// default settings.)
|
||||
pub fn engines_status_for(settings: &OcrSettings) -> Vec<EngineStatus> {
|
||||
let ocrs = Models::find().is_some();
|
||||
let ocrs = ocrs_loaded() || Models::find().is_some();
|
||||
let mut out = vec![EngineStatus { id: "ocrs", available: ocrs, why_not: (!ocrs).then(models_missing), program: None, version: None }];
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
@@ -524,7 +543,6 @@ pub fn recognizers(settings: &OcrSettings) -> Result<Recognizers, String> {
|
||||
let configured = configured_tesseract(settings);
|
||||
let tesseract =
|
||||
if matches!(settings.engine, EngineChoice::Tesseract | EngineChoice::Auto) { tesseract_engine(configured.as_deref()) } else { None };
|
||||
static ENGINES: Mutex<Option<EngineCache>> = Mutex::new(None);
|
||||
let mut slot = ENGINES.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let cache = slot.get_or_insert_with(|| EngineCache { ocrs: None });
|
||||
if cache.ocrs.is_none() {
|
||||
@@ -567,7 +585,7 @@ fn models_missing() -> String {
|
||||
/// never waits.
|
||||
pub fn available() -> bool {
|
||||
let configured = std::env::var_os("PDFCRAFT_TESSERACT").map(|v| v.to_string_lossy().into_owned());
|
||||
Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
|
||||
ocrs_loaded() || Models::find().is_some() || tesseract_engine(configured.as_deref()).is_some()
|
||||
}
|
||||
|
||||
/// Why `engine` cannot run right now, named for the UI's tooltips (the Engine dropdown
|
||||
@@ -703,138 +721,226 @@ impl OcrJob {
|
||||
/// Read the pages. `progress(done, total)` is called before each page; returning `false`
|
||||
/// stops (the pages read so far are returned).
|
||||
pub fn run(self, recognizers: &Recognizers, mut progress: impl FnMut(usize, usize) -> bool) -> Vec<OcrPage> {
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let mut r = PageRenderer::new(self.bytes.clone(), config);
|
||||
let mut out = Vec::new();
|
||||
let total = self.pages.len();
|
||||
let options = pdfcraft_ocr::RecognizeOptions { language: self.settings.language.clone() };
|
||||
for (done, &page) in self.pages.iter().enumerate() {
|
||||
if !progress(done, total) {
|
||||
break;
|
||||
let mut steps = self.steps();
|
||||
let mut out = Vec::new();
|
||||
while progress(steps.done(), total) {
|
||||
match steps.next_page() {
|
||||
None => break,
|
||||
Some(Err(skipped)) => out.push(skipped),
|
||||
Some(Ok(shot)) => out.push(shot.read(recognizers)),
|
||||
}
|
||||
let Some(info) = self.infos.get(page) else { continue };
|
||||
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
|
||||
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
|
||||
// only /ActualText names, so neither is read again.
|
||||
let existing: Vec<[f32; 4]> = r
|
||||
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
|
||||
.text
|
||||
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
|
||||
.unwrap_or_default();
|
||||
if self.settings.skip_text_pages && !self.settings.force_ocr && !existing.is_empty() && has_own_text(&mut r, page) {
|
||||
out.push(skip("the page already contains text"));
|
||||
continue;
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at
|
||||
// most the chosen one.
|
||||
let scale = self.render_scale(page);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
out.push(skip(&e));
|
||||
continue;
|
||||
}
|
||||
let full = match pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()) {
|
||||
Ok(img) => img,
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
// The optional region: crop the rendered page, clamped; an empty region skips the
|
||||
// page with a named reason (nothing was asked of it).
|
||||
let (image, offset) = match self.settings.region {
|
||||
None => (full, [0.0f32; 2]),
|
||||
Some(region) => match clamp_region(region, shot.width, shot.height) {
|
||||
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
|
||||
Some(c) => (c, [x0 as f32, y0 as f32]),
|
||||
None => {
|
||||
out.push(skip("the selected region is empty"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
None => {
|
||||
out.push(skip("the selected region is outside the page"));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
// Preprocessing sits between the page raster and recognition; its word boxes come
|
||||
// back in processed coordinates and are mapped to the ORIGINAL raster's before
|
||||
// placement. The render dpi is the real one (pixels per point × 72), never a
|
||||
// fixed value.
|
||||
let pre = pdfcraft_ocr::preprocess::preprocess(
|
||||
image,
|
||||
scale * 72.0,
|
||||
&pdfcraft_ocr::preprocess::PreprocessOptions {
|
||||
auto_rotate: self.settings.auto_rotate,
|
||||
deskew: self.settings.deskew,
|
||||
denoise: self.settings.denoise,
|
||||
binarize: self.settings.binarize,
|
||||
},
|
||||
);
|
||||
let (image, inverse) = match pre {
|
||||
Ok(p) => (p.image, p.inverse_transform),
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let outcome = match pdfcraft_ocr::recognize_with_strategy(
|
||||
recognizers.primary.as_ref(),
|
||||
recognizers.secondary.as_deref(),
|
||||
self.settings.strategy,
|
||||
&image,
|
||||
&options,
|
||||
) {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
out.push(skip(&e.to_string()));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let scale = shot.width as f32 / info.width.max(1e-3);
|
||||
let to_user = |x: f32, y: f32| {
|
||||
// processed pixels → original raster pixels (the exact inverse) → past the
|
||||
// region crop → page view space.
|
||||
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
|
||||
[u as f64, v as f64]
|
||||
};
|
||||
// Validate recognition geometry and compare against existing text in view space,
|
||||
// undoing preprocessing and region offsets before testing overlap.
|
||||
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
|
||||
let [l, t, r, b] = w.rect;
|
||||
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
|
||||
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|
||||
|mut bounds, (x, y)| {
|
||||
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let x = (x as f32 + offset[0]) / scale;
|
||||
let y = (y as f32 + offset[1]) / scale;
|
||||
bounds[0] = bounds[0].min(x);
|
||||
bounds[1] = bounds[1].min(y);
|
||||
bounds[2] = bounds[2].max(x);
|
||||
bounds[3] = bounds[3].max(y);
|
||||
bounds
|
||||
},
|
||||
);
|
||||
covered(bounds, &existing) < ALREADY_ON_PAGE
|
||||
});
|
||||
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
|
||||
let mut notes = outcome.notes;
|
||||
// Only visible editable output needs display glyphs. Searchable output preserves
|
||||
// every Unicode character with empty Type3 programs and ToUnicode maps.
|
||||
if self.settings.output_mode == OutputMode::EditableText && !words.is_empty() {
|
||||
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
|
||||
if missing > 0 {
|
||||
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
|
||||
}
|
||||
}
|
||||
out.push(OcrPage { page, words, already_on_page: already.len(), skipped: None, notes });
|
||||
}
|
||||
progress(total, total);
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the pages one at a time, preparing each page's image and recognising it separately:
|
||||
/// the web build prepares pages on the page and recognises them in Web Workers.
|
||||
pub fn steps(self) -> OcrSteps {
|
||||
let config =
|
||||
RenderConfig { password: self.password.as_deref().map(Arc::from), hide_comments: self.settings.native_text_only, ..Default::default() };
|
||||
let renderer = PageRenderer::new(self.bytes.clone(), config);
|
||||
OcrSteps { job: self, renderer, next: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
/// [`OcrJob`] a page at a time: [`OcrSteps::next_page`] renders the next page, crops it to the
|
||||
/// region and preprocesses it; an engine reads [`OcrShot::image`], and [`OcrShot::place`] puts
|
||||
/// the words found on the page ([`OcrShot::read`] does both with the job's engines).
|
||||
pub struct OcrSteps {
|
||||
job: OcrJob,
|
||||
renderer: PageRenderer,
|
||||
next: usize,
|
||||
}
|
||||
|
||||
impl OcrSteps {
|
||||
/// The number of pages to read.
|
||||
pub fn total(&self) -> usize {
|
||||
self.job.pages.len()
|
||||
}
|
||||
|
||||
/// The number of pages prepared (or skipped) so far.
|
||||
pub fn done(&self) -> usize {
|
||||
self.next
|
||||
}
|
||||
|
||||
/// The settings the job reads with.
|
||||
pub fn settings(&self) -> &OcrSettings {
|
||||
&self.job.settings
|
||||
}
|
||||
|
||||
/// Prepare the next page for recognition. `Err` is a page that isn't read (it already has
|
||||
/// text, or couldn't be rendered or prepared), with the reason; `None` once every page is
|
||||
/// done.
|
||||
pub fn next_page(&mut self) -> Option<Result<OcrShot, OcrPage>> {
|
||||
loop {
|
||||
let page = *self.job.pages.get(self.next)?;
|
||||
self.next += 1;
|
||||
let Some(info) = self.job.infos.get(page).cloned() else { continue };
|
||||
return Some(self.prepare(page, info));
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare(&mut self, page: usize, info: PageInfo) -> Result<OcrShot, OcrPage> {
|
||||
let settings = &self.job.settings;
|
||||
let skip = |why: &str| OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(why.into()), notes: Vec::new() };
|
||||
let r = &mut self.renderer;
|
||||
// The boxes of all text on the page, in view space: an earlier OCR layer's, and words
|
||||
// only /ActualText names, so neither is read again.
|
||||
let existing: Vec<[f32; 4]> = r
|
||||
.render(RenderRequest { page, kind: RequestKind::Text, scale: 1.0, ..Default::default() })
|
||||
.text
|
||||
.map(|t| t.glyphs.iter().filter(|g| !g.text.trim().is_empty()).map(|g| g.rect).collect())
|
||||
.unwrap_or_default();
|
||||
if settings.skip_text_pages && !settings.force_ocr && !existing.is_empty() && has_own_text(r, page) {
|
||||
return Err(skip("the page already contains text"));
|
||||
}
|
||||
// A scan is read at its own resolution (enlarging it only blurs the letters), at
|
||||
// most the chosen one.
|
||||
let scale = self.job.render_scale(page);
|
||||
let shot = r.render(RenderRequest { page, scale, ..Default::default() });
|
||||
if let Some(e) = shot.error {
|
||||
return Err(skip(&e));
|
||||
}
|
||||
let full = pdfcraft_ocr::OcrImage::new(shot.width, shot.height, shot.rgba.to_vec()).map_err(|e| skip(&e.to_string()))?;
|
||||
// The optional region: crop the rendered page, clamped; an empty region skips the
|
||||
// page with a named reason (nothing was asked of it).
|
||||
let (image, offset) = match settings.region {
|
||||
None => (full, [0.0f32; 2]),
|
||||
Some(region) => match clamp_region(region, shot.width, shot.height) {
|
||||
Some([x0, y0, x1, y1]) => match crop(&full, x0, y0, x1, y1) {
|
||||
Some(c) => (c, [x0 as f32, y0 as f32]),
|
||||
None => return Err(skip("the selected region is empty")),
|
||||
},
|
||||
None => return Err(skip("the selected region is outside the page")),
|
||||
},
|
||||
};
|
||||
// Preprocessing sits between the page raster and recognition; its word boxes come
|
||||
// back in processed coordinates and are mapped to the ORIGINAL raster's before
|
||||
// placement. The render dpi is the real one (pixels per point × 72), never a
|
||||
// fixed value.
|
||||
let pre = pdfcraft_ocr::preprocess::preprocess(
|
||||
image,
|
||||
scale * 72.0,
|
||||
&pdfcraft_ocr::preprocess::PreprocessOptions {
|
||||
auto_rotate: settings.auto_rotate,
|
||||
deskew: settings.deskew,
|
||||
denoise: settings.denoise,
|
||||
binarize: settings.binarize,
|
||||
},
|
||||
)
|
||||
.map_err(|e| skip(&e.to_string()))?;
|
||||
Ok(OcrShot {
|
||||
page,
|
||||
image: Some(pre.image),
|
||||
inverse: pre.inverse_transform,
|
||||
offset,
|
||||
pixels_per_point: shot.width as f32 / info.width.max(1e-3),
|
||||
info,
|
||||
existing,
|
||||
strategy: settings.strategy,
|
||||
options: pdfcraft_ocr::RecognizeOptions { language: settings.language.clone() },
|
||||
output_mode: settings.output_mode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A page prepared for recognition: the image an engine reads, and what placing the words
|
||||
/// found in it needs.
|
||||
pub struct OcrShot {
|
||||
pub page: usize,
|
||||
/// The page's pixels as the engines read them: rendered, cropped to the region and
|
||||
/// preprocessed (`None` once [`OcrShot::take_image`] has handed them on).
|
||||
image: Option<OcrImage>,
|
||||
/// Processed pixels → the rendered raster's (the exact inverse of preprocessing).
|
||||
inverse: pdfcraft_ocr::preprocess::InverseTransform,
|
||||
/// Where the region crop starts in the rendered raster.
|
||||
offset: [f32; 2],
|
||||
/// The rendered raster's pixels per point of page view space.
|
||||
pixels_per_point: f32,
|
||||
info: PageInfo,
|
||||
/// The boxes of the page's own text, in view space.
|
||||
existing: Vec<[f32; 4]>,
|
||||
strategy: MergeStrategy,
|
||||
options: pdfcraft_ocr::RecognizeOptions,
|
||||
output_mode: OutputMode,
|
||||
}
|
||||
|
||||
impl OcrShot {
|
||||
/// What the engines read (`None` once taken).
|
||||
pub fn image(&self) -> Option<&OcrImage> {
|
||||
self.image.as_ref()
|
||||
}
|
||||
|
||||
/// Hand the image on (the web build sends it to a worker), keeping what placing the words
|
||||
/// needs: the page's pixels need not stay in memory while the worker reads them.
|
||||
pub fn take_image(&mut self) -> Option<OcrImage> {
|
||||
self.image.take()
|
||||
}
|
||||
|
||||
/// Recognise the image with `recognizers` (per the job's strategy) and place the words.
|
||||
pub fn read(&self, recognizers: &Recognizers) -> OcrPage {
|
||||
let Some(image) = &self.image else { return self.place(Err("the page's image was handed to another reader".into())) };
|
||||
let outcome = pdfcraft_ocr::recognize_with_strategy(
|
||||
recognizers.primary.as_ref(),
|
||||
recognizers.secondary.as_deref(),
|
||||
self.strategy,
|
||||
image,
|
||||
&self.options,
|
||||
);
|
||||
self.place(outcome.map_err(|e| e.to_string()))
|
||||
}
|
||||
|
||||
/// Put the lines recognised in [`Self::image`] on the page (or skip the page with the
|
||||
/// error). A single engine's reading, which is all the web build's workers run, is
|
||||
/// `StrategyOutcome { lines, ..Default::default() }`: every strategy reads that way when
|
||||
/// there is no second engine.
|
||||
pub fn place(&self, outcome: Result<StrategyOutcome, String>) -> OcrPage {
|
||||
let page = self.page;
|
||||
let outcome = match outcome {
|
||||
Ok(o) => o,
|
||||
Err(e) => return OcrPage { page, words: Vec::new(), already_on_page: 0, skipped: Some(e), notes: Vec::new() },
|
||||
};
|
||||
let (info, inverse, offset, scale) = (&self.info, self.inverse, self.offset, self.pixels_per_point);
|
||||
let to_user = |x: f32, y: f32| {
|
||||
// processed pixels → original raster pixels (the exact inverse) → past the
|
||||
// region crop → page view space.
|
||||
let (ox, oy) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let [u, v] = info.view_to_user((ox as f32 + offset[0]) / scale, (oy as f32 + offset[1]) / scale);
|
||||
[u as f64, v as f64]
|
||||
};
|
||||
// Validate recognition geometry and compare against existing text in view space,
|
||||
// undoing preprocessing and region offsets before testing overlap.
|
||||
let (fresh, already): (Vec<_>, Vec<_>) = outcome.lines.iter().flat_map(|l| &l.words).filter(|w| box_writable(w)).partition(|w| {
|
||||
let [l, t, r, b] = w.rect;
|
||||
let bounds = [(l, t), (r, t), (r, b), (l, b)].into_iter().fold(
|
||||
[f32::INFINITY, f32::INFINITY, f32::NEG_INFINITY, f32::NEG_INFINITY],
|
||||
|mut bounds, (x, y)| {
|
||||
let (x, y) = inverse.apply(f64::from(x), f64::from(y));
|
||||
let x = (x as f32 + offset[0]) / scale;
|
||||
let y = (y as f32 + offset[1]) / scale;
|
||||
bounds[0] = bounds[0].min(x);
|
||||
bounds[1] = bounds[1].min(y);
|
||||
bounds[2] = bounds[2].max(x);
|
||||
bounds[3] = bounds[3].max(y);
|
||||
bounds
|
||||
},
|
||||
);
|
||||
covered(bounds, &self.existing) < ALREADY_ON_PAGE
|
||||
});
|
||||
let words = fresh.into_iter().map(|w| PlacedWord::place(w, to_user)).collect::<Vec<_>>();
|
||||
let mut notes = outcome.notes;
|
||||
// Only visible editable output needs display glyphs. Searchable output preserves
|
||||
// every Unicode character with empty Type3 programs and ToUnicode maps.
|
||||
if self.output_mode == OutputMode::EditableText && !words.is_empty() {
|
||||
let missing = pdfcraft_fonts::TextPlan::for_words(words.iter().map(|w| w.text.as_str())).missing();
|
||||
if missing > 0 {
|
||||
notes.push(format!("{missing} characters had no font that could show them and appear as ? in the output"));
|
||||
}
|
||||
}
|
||||
OcrPage { page, words, already_on_page: already.len(), skipped: None, notes }
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a word's pixel box may reach the content stream: width and height (right − left,
|
||||
@@ -1095,36 +1201,70 @@ pub fn recognize_file(
|
||||
recognizers: &Recognizers,
|
||||
progress: impl FnMut(usize, usize) -> bool,
|
||||
) -> Result<FileResult, String> {
|
||||
let mut s = Session::new();
|
||||
let id = s.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
if let Some(why) = s.get(id).and_then(|d| d.read_only_reason.clone()) {
|
||||
return Err(why);
|
||||
match FileJob::open(name, bytes, password, settings)? {
|
||||
Opened::Skipped(result) => Ok(result),
|
||||
Opened::Read(mut file) => {
|
||||
let job = file.job.take().ok_or("the document could not be read")?;
|
||||
let pages = job.run(recognizers, progress);
|
||||
file.finish(pages)
|
||||
}
|
||||
}
|
||||
let job = s.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
|
||||
// The skip-files probe runs before any engine work: a file with text needs no engine at
|
||||
// all, and the original bytes come back unchanged.
|
||||
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
|
||||
let pages = job
|
||||
.pages
|
||||
.iter()
|
||||
.map(|&page| OcrPage {
|
||||
already_on_page: 0,
|
||||
page,
|
||||
words: Vec::new(),
|
||||
skipped: Some("the file already contains text".into()),
|
||||
notes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) });
|
||||
}
|
||||
|
||||
/// [`recognize_file`] in two halves, for callers that read the pages themselves (the web build
|
||||
/// reads them in Web Workers): [`FileJob::open`], read `job`, then [`FileJob::finish`].
|
||||
pub struct FileJob {
|
||||
session: Session,
|
||||
id: DocId,
|
||||
bytes: Arc<Vec<u8>>,
|
||||
/// The pages to read (taken by the caller).
|
||||
pub job: Option<OcrJob>,
|
||||
}
|
||||
|
||||
/// What [`FileJob::open`] found: a file to read, or one the skip-files option passes over
|
||||
/// (its result is final, and no engine is needed).
|
||||
pub enum Opened {
|
||||
Read(Box<FileJob>),
|
||||
Skipped(FileResult),
|
||||
}
|
||||
|
||||
impl FileJob {
|
||||
pub fn open(name: &str, bytes: Arc<Vec<u8>>, password: Option<&str>, settings: OcrSettings) -> Result<Opened, String> {
|
||||
let mut session = Session::new();
|
||||
let id = session.open(name, None, bytes.clone(), password).map_err(|e| e.to_string())?;
|
||||
if let Some(why) = session.get(id).and_then(|d| d.read_only_reason.clone()) {
|
||||
return Err(why);
|
||||
}
|
||||
let job = session.ocr_job(id, &[], settings.clone()).ok_or("the document could not be read")?;
|
||||
// The skip-files probe runs before any engine work: a file with text needs no engine at
|
||||
// all, and the original bytes come back unchanged.
|
||||
if settings.skip_text_files && !settings.force_ocr && job.has_text() {
|
||||
let pages = job
|
||||
.pages
|
||||
.iter()
|
||||
.map(|&page| OcrPage {
|
||||
already_on_page: 0,
|
||||
page,
|
||||
words: Vec::new(),
|
||||
skipped: Some("the file already contains text".into()),
|
||||
notes: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
return Ok(Opened::Skipped(FileResult { bytes, pages, file_skipped: Some("the file already contains text".into()) }));
|
||||
}
|
||||
Ok(Opened::Read(Box::new(FileJob { session, id, bytes, job: Some(job) })))
|
||||
}
|
||||
let pages = job.run(recognizers, progress);
|
||||
if s.apply_ocr(id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes, pages, file_skipped: None });
|
||||
|
||||
/// Add the words found and save: the searchable file, or the original when nothing was found.
|
||||
pub fn finish(mut self, pages: Vec<OcrPage>) -> Result<FileResult, String> {
|
||||
if self.session.apply_ocr(self.id, &pages).map_err(|e| e.to_string())? == 0 {
|
||||
return Ok(FileResult { bytes: self.bytes, pages, file_skipped: None });
|
||||
}
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = self.session.save_bytes(self.id).map_err(|e| e.to_string())?;
|
||||
self.session.check_save(self.id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages, file_skipped: None })
|
||||
}
|
||||
// Checked like a save before the caller writes it (#2166).
|
||||
let bytes = s.save_bytes(id).map_err(|e| e.to_string())?;
|
||||
s.check_save(id, &bytes).map_err(|e| e.to_string())?;
|
||||
Ok(FileResult { bytes, pages, file_skipped: None })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1639,6 +1779,89 @@ mod tests {
|
||||
assert_eq!(again.pages[0].skipped.as_deref(), Some("the page already contains text"));
|
||||
}
|
||||
|
||||
/// The web build prepares pages with [`OcrJob::steps`] and has a worker read each image,
|
||||
/// its lines coming back as JSON: the pages it ends with are the ones [`OcrJob::run`] gives,
|
||||
/// with a region crop and preprocessing between the render and the engine.
|
||||
#[test]
|
||||
fn reading_page_by_page_matches_a_run() {
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_text("t", "One page\nanother page").unwrap();
|
||||
let id = s.open("t.pdf", None, bytes, None).unwrap();
|
||||
let settings = OcrSettings { force_ocr: true, deskew: true, binarize: true, region: Some([40.0, 30.0, 900.0, 700.0]), ..Default::default() };
|
||||
let (r, fake) = fake_recognizers();
|
||||
let run = s.ocr_job(id, &[], settings.clone()).unwrap().run(&r, |_, _| true);
|
||||
assert!(run.iter().all(|p| p.skipped.is_none() && p.words.len() == 1), "{run:?}");
|
||||
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
|
||||
assert_eq!((steps.done(), steps.total()), (0, run.len()));
|
||||
let mut stepped = Vec::new();
|
||||
while let Some(next) = steps.next_page() {
|
||||
let shot = next.unwrap();
|
||||
let json = pdfcraft_ocr::lines_to_json(&fake.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap());
|
||||
let lines = lines_from_json(&json, "fake").map_err(|e| e.to_string());
|
||||
stepped.push(shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() })));
|
||||
}
|
||||
assert_eq!(steps.done(), run.len());
|
||||
assert_eq!(stepped, run);
|
||||
// An engine's failure skips the page with the reason, as in a run.
|
||||
let mut steps = s.ocr_job(id, &[0], OcrSettings { force_ocr: true, ..Default::default() }).unwrap().steps();
|
||||
let mut shot = steps.next_page().unwrap().unwrap();
|
||||
let page = shot.place(Err("worker gone".into()));
|
||||
assert_eq!(page.skipped.as_deref(), Some("worker gone"));
|
||||
// Once the image is handed on, it can't be read here as well.
|
||||
assert!(shot.take_image().is_some() && shot.image().is_none());
|
||||
assert!(shot.read(&r).skipped.is_some());
|
||||
assert!(steps.next_page().is_none());
|
||||
}
|
||||
|
||||
/// A page the job doesn't read comes out of [`OcrSteps::next_page`] as skipped, with its
|
||||
/// reason, before any engine is involved.
|
||||
#[test]
|
||||
fn page_by_page_skips_text_pages_without_an_engine() {
|
||||
let mut s = Session::new();
|
||||
let bytes = s.create_from_text("t", "A page with text").unwrap();
|
||||
let id = s.open("t.pdf", None, bytes, None).unwrap();
|
||||
let mut steps = s.ocr_job(id, &[], OcrSettings::default()).unwrap().steps();
|
||||
let Some(Err(page)) = steps.next_page() else { panic!("the text page should be skipped") };
|
||||
assert_eq!(page.skipped.as_deref(), Some("the page already contains text"));
|
||||
}
|
||||
|
||||
/// The web build has no model files: a recogniser loaded from downloaded bytes is installed
|
||||
/// and from then on is the engine every job reads with.
|
||||
#[test]
|
||||
fn an_installed_recognizer_is_available_and_handed_out() {
|
||||
let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else {
|
||||
eprintln!("skipped: ONNX OCR models not installed");
|
||||
return;
|
||||
};
|
||||
let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap();
|
||||
let installed = install(ocr);
|
||||
assert!(installed.available());
|
||||
assert!(available());
|
||||
let r = recognizers(&OcrSettings::default()).unwrap();
|
||||
assert!(Arc::ptr_eq(&r.primary, &installed), "the installed recogniser is the one handed out");
|
||||
assert!(engines_status().iter().any(|e| e.id == "ocrs" && e.available));
|
||||
}
|
||||
|
||||
/// The batch's halves: a file to read opens with its job, and [`FileJob::finish`] saves
|
||||
/// the searchable copy; a file the skip-files option passes over is final on opening.
|
||||
#[test]
|
||||
fn a_file_job_reads_in_two_halves() {
|
||||
let s = Session::new();
|
||||
let bytes = s.create_from_text("t", "Already typed here").unwrap();
|
||||
let (r, fake) = fake_recognizers();
|
||||
let skip = OcrSettings { skip_text_files: true, ..Default::default() };
|
||||
let Ok(Opened::Skipped(out)) = FileJob::open("t.pdf", bytes.clone(), None, skip) else { panic!("the file should be skipped") };
|
||||
assert_eq!(out.file_skipped.as_deref(), Some("the file already contains text"));
|
||||
let force = OcrSettings { force_ocr: true, ..Default::default() };
|
||||
let Ok(Opened::Read(mut file)) = FileJob::open("t.pdf", bytes.clone(), None, force.clone()) else { panic!("the file should be read") };
|
||||
let pages = file.job.take().unwrap().run(&r, |_, _| true);
|
||||
let out = file.finish(pages).unwrap();
|
||||
assert_eq!((fake.calls(), out.words(), out.file_skipped.clone()), (1, 1, None));
|
||||
let whole = recognize_file("t.pdf", bytes, None, force, &r, |_, _| true).unwrap();
|
||||
// The saved bytes carry a modification time, so two saves a second apart differ there alone.
|
||||
assert_eq!((out.pages, out.file_skipped), (whole.pages, whole.file_skipped), "the same as reading it in one call");
|
||||
}
|
||||
|
||||
/// Two inputs of one name (from different folders, or differing only in case) would
|
||||
/// write the same output: both are found, each once.
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,19 @@ fn deletes_pages(edit: &Edit) -> bool {
|
||||
impl Session {
|
||||
/// Apply an edit. On success the previous state is undoable and the view data is refreshed.
|
||||
pub fn apply(&mut self, id: DocId, edit: Edit) -> Result<(), EditError> {
|
||||
self.apply_reporting(id, edit).map(|_| ())
|
||||
}
|
||||
|
||||
/// Edit a PDF ▸ Edit text: make the grouped text on `page` (0-based) editable, as one undoable
|
||||
/// [`Edit::PrepareText`], and say what it prepared, which fonts' maps it repaired and which
|
||||
/// groups it left as they were (and why). The counts are this edit's own, not a preview's.
|
||||
pub fn prepare_text(&mut self, id: DocId, page: usize) -> Result<pdfcraft_edit::text::forms::Preparation, EditError> {
|
||||
self.apply_reporting(id, Edit::PrepareText { page })?
|
||||
.ok_or_else(|| EditError::Invalid("preparing grouped text gave no result; the document was not changed".into()))
|
||||
}
|
||||
|
||||
/// [`Session::apply`], giving back what an [`Edit::PrepareText`] did (`None` for other edits).
|
||||
fn apply_reporting(&mut self, id: DocId, edit: Edit) -> Result<Option<pdfcraft_edit::text::forms::Preparation>, EditError> {
|
||||
let now = self.now();
|
||||
let today = self.today();
|
||||
let js_off = self.js_off;
|
||||
@@ -61,7 +74,7 @@ impl Session {
|
||||
};
|
||||
// Nothing to bake: leave undo history and the dirty flag alone.
|
||||
if matches!(&edit, Edit::FlattenFillSign) && !pdfcraft_annot::has_visible_fill_sign(&editor.cos) {
|
||||
return Ok(());
|
||||
return Ok(None);
|
||||
}
|
||||
let mut next = editor.cos.clone();
|
||||
if !js_off && uses_scripts(&edit) {
|
||||
@@ -228,7 +241,7 @@ impl Session {
|
||||
doc.xfa_scripts_off = true;
|
||||
doc.js_output.errors.push(XFA_SCRIPTS_OFF.into());
|
||||
}
|
||||
Ok(())
|
||||
Ok(cx.prepared)
|
||||
}
|
||||
|
||||
pub fn undo(&mut self, id: DocId) -> Result<Label, EditError> {
|
||||
|
||||
@@ -5801,6 +5801,35 @@ fn backgrounds_and_watermarks_from_files() {
|
||||
assert!(s.apply(id, Edit::AddBackground { pages: vec![0], settings: Background::default(), replace: true, file: Some(junk) }).is_err());
|
||||
}
|
||||
|
||||
/// The web build reads a page at a time (prepared on the page, recognised in a worker, the
|
||||
/// lines sent back as JSON); with the real ocrs engine that finds the same words as reading in
|
||||
/// one go (needs the models: `cargo xtask models`; skipped without them).
|
||||
#[test]
|
||||
fn reading_a_page_at_a_time_finds_the_same_words() {
|
||||
let settings = ocr::OcrSettings::default();
|
||||
let Ok(engines) = ocr::recognizers(&settings) else {
|
||||
eprintln!("skipped: OCR models not installed");
|
||||
return;
|
||||
};
|
||||
let mut s = Session::new().with_clock(|| 1_700_000_000);
|
||||
let text = s.create_from_text("t", "Invoice 42: pay by Friday").unwrap();
|
||||
let typed = s.open("text.pdf", None, text, None).unwrap();
|
||||
let png = export::Exporter::new(s.get(typed).unwrap()).png(0, 150.0).unwrap();
|
||||
let scan = s.create_from_images(&[("scan.png".into(), png.clone()), ("scan2.png".into(), png)]).unwrap();
|
||||
let id = s.open("scan.pdf", None, scan, None).unwrap();
|
||||
let at_once = s.ocr_job(id, &[], settings.clone()).unwrap().run(&engines, |_, _| true);
|
||||
let mut steps = s.ocr_job(id, &[], settings).unwrap().steps();
|
||||
let mut one_by_one = Vec::new();
|
||||
while let Some(next) = steps.next_page() {
|
||||
let shot = next.unwrap();
|
||||
let lines = engines.primary.recognize(shot.image().unwrap(), &pdfcraft_ocr::RecognizeOptions::default()).unwrap();
|
||||
let sent = ocr::lines_from_json(&pdfcraft_ocr::lines_to_json(&lines), engines.primary.id()).map_err(|e| e.to_string());
|
||||
one_by_one.push(shot.place(sent.map(|lines| ocr::StrategyOutcome { lines, ..Default::default() })));
|
||||
}
|
||||
assert_eq!(one_by_one, at_once);
|
||||
assert!(at_once.len() == 2 && at_once.iter().all(|p| p.skipped.is_none() && !p.words.is_empty()), "{at_once:?}");
|
||||
}
|
||||
|
||||
/// Scan & OCR ▸ Recognize text on a page that is only a picture of text (needs the models:
|
||||
/// `cargo xtask models`; skipped without them).
|
||||
#[test]
|
||||
@@ -6505,6 +6534,35 @@ fn actions_fail_when_hidden_information_could_not_be_removed() {
|
||||
assert!(e.starts_with("Sanitize document:"), "{e}");
|
||||
}
|
||||
|
||||
/// A PDF whose catalog names its language (`/Lang`) exports to Word and HTML in that language, so
|
||||
/// Word checks the spelling in it, not in English; one that names none exports as before.
|
||||
#[test]
|
||||
fn office_exports_take_the_documents_language() {
|
||||
let pdf = |catalog: &str| {
|
||||
pdfcraft_testkit::Pdf::new()
|
||||
.obj(catalog)
|
||||
.obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>")
|
||||
.obj("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources << /Font << /F1 << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> >> >> >>")
|
||||
.stream("", b"BT /F1 12 Tf 20 200 Td (Hello) Tj ET")
|
||||
.build()
|
||||
};
|
||||
let mut s = Session::new();
|
||||
let bg = s.open("lang.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R /Lang (bg-BG) >>")), None).unwrap();
|
||||
let plain = s.open("plain.pdf", None, Arc::new(pdf("<< /Type /Catalog /Pages 2 0 R >>")), None).unwrap();
|
||||
let d = s.get(bg).unwrap();
|
||||
let docx = d.export_office(compare::OfficeFormat::Docx);
|
||||
assert!(docx == pdfcraft_export::docx_with_language(&d.export_pages(), "lang", Some("bg-BG")), "Word names bg-BG");
|
||||
assert!(docx != pdfcraft_export::docx(&d.export_pages(), "lang"), "Word names a language");
|
||||
for format in [compare::OfficeFormat::Html, compare::OfficeFormat::HtmlSimple] {
|
||||
let html = String::from_utf8(d.export_office(format)).unwrap();
|
||||
assert!(html.contains("<html lang=\"bg-BG\">"), "{html}");
|
||||
}
|
||||
let d = s.get(plain).unwrap();
|
||||
assert!(d.export_office(compare::OfficeFormat::Docx) == pdfcraft_export::docx(&d.export_pages(), "plain"), "no language, as before");
|
||||
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::Html)).unwrap().contains("<html>\n"));
|
||||
assert!(String::from_utf8(d.export_office(compare::OfficeFormat::HtmlSimple)).unwrap().contains("<html>\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exporting_office_files_keeps_images() {
|
||||
let mut s = Session::new();
|
||||
|
||||
@@ -160,6 +160,116 @@ fn a_rewritten_ocr_phrase_keeps_its_word_widths() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize text sizes each word from its box and a guess at how much of the em its letters fill,
|
||||
/// so one phrase's words come out several points apart (#3313). Here the first word is the
|
||||
/// largest and the middle one the smallest; the phrase is set in 11 pt.
|
||||
fn uneven_phrase() -> (Session, pdfcraft_engine::DocId) {
|
||||
scanned_with(vec![
|
||||
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
|
||||
scanned_word("Smith", 49.0, 78.0, 70.0, 9.0),
|
||||
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
|
||||
])
|
||||
}
|
||||
|
||||
/// An edited OCR phrase is redrawn in one size, the median of its words', and each word keeps
|
||||
/// the width the scan shows it at (#3313).
|
||||
#[test]
|
||||
fn an_edited_ocr_phrase_is_drawn_at_one_size() {
|
||||
let (mut s, id) = uneven_phrase();
|
||||
let block = s.get(id).unwrap().text_blocks(0).remove(0);
|
||||
assert!((block.size - 11.0).abs() < 0.05, "the phrase's size is its words' median: {}", block.size);
|
||||
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Example".into(), style: Default::default() }).unwrap();
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.iter().map(|l| l.text.trim()).collect::<Vec<_>>(), ["John", "Example"]);
|
||||
for line in &lines {
|
||||
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
|
||||
}
|
||||
// Each word keeps its width; the rest of the phrase closes up where Smith was.
|
||||
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
|
||||
assert!((lines[1].rect[2] - lines[1].rect[0] - 42.0).abs() < 0.5, "Example keeps its width: {:?}", lines[1].rect);
|
||||
assert!(lines[1].rect[0] >= lines[0].rect[2] - 0.5, "Example runs into John");
|
||||
}
|
||||
|
||||
/// Only invisible OCR text is set in one phrase size: visible text keeps each run's own size, and
|
||||
/// a phrase whose size is NaN, zero, negative or infinite falls back to its words' sizes (#3313).
|
||||
#[test]
|
||||
fn only_an_ocr_phrase_with_a_usable_size_is_set_in_one_size() {
|
||||
let (s, id) = uneven_phrase();
|
||||
let block = s.get(id).unwrap().text_blocks(0).remove(0);
|
||||
assert!(block.invisible);
|
||||
assert_eq!(block.phrase_size(), Some(block.size));
|
||||
let visible = pdfcraft_engine::TextBlock { invisible: false, ..block.clone() };
|
||||
assert_eq!(visible.phrase_size(), None, "visible text keeps its runs' sizes");
|
||||
for size in [f64::NAN, 0.0, -3.0, f64::INFINITY] {
|
||||
assert_eq!(pdfcraft_engine::TextBlock { size, ..block.clone() }.phrase_size(), None, "a {size} pt phrase falls back to its words");
|
||||
}
|
||||
}
|
||||
|
||||
/// Choosing a font sends the phrase through the single-style rewrite, which takes its size from
|
||||
/// the first run: that is the phrase's size too, not the first word's guess.
|
||||
#[test]
|
||||
fn a_new_font_for_an_ocr_phrase_keeps_its_one_size() {
|
||||
let (mut s, id) = uneven_phrase();
|
||||
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Times, false, false)), ..Default::default() };
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Example".into(), style }).unwrap();
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert!(!lines.is_empty());
|
||||
for line in &lines {
|
||||
assert!((line.size - 11.0).abs() < 0.05, "{:?} is drawn at {} pt, not the phrase's 11", line.text, line.size);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two neighbouring words OCR sized alike join into one run; the median still counts each word,
|
||||
/// so 15, 9 and 9 pt make a 9 pt phrase, not the 12 pt of its two runs.
|
||||
#[test]
|
||||
fn words_joined_into_one_run_each_count_toward_the_phrase_size() {
|
||||
let (s, id) = scanned_with(vec![
|
||||
scanned_word("John", 20.0, 45.0, 70.0, 15.0),
|
||||
scanned_word("SMITH", 49.0, 78.0, 70.0, 9.0),
|
||||
scanned_word("JONES", 82.0, 112.0, 70.0, 9.0),
|
||||
]);
|
||||
let doc = s.get(id).unwrap();
|
||||
assert_eq!(doc.text_lines(0).len(), 2, "the two 9 pt words share a run");
|
||||
let size = doc.text_blocks(0)[0].size;
|
||||
assert!((size - 9.0).abs() < 0.05, "the phrase is set in its words' median, 9 pt: {size}");
|
||||
}
|
||||
|
||||
/// A word OCR sized at more than twice the phrase's size still keeps the width it has on the scan.
|
||||
#[test]
|
||||
fn a_word_far_larger_than_its_phrase_keeps_its_width() {
|
||||
let (mut s, id) = scanned_with(vec![
|
||||
scanned_word("John", 20.0, 45.0, 70.0, 25.0),
|
||||
scanned_word("Smith", 49.0, 78.0, 70.0, 10.0),
|
||||
scanned_word("Example", 82.0, 124.0, 70.0, 10.5),
|
||||
]);
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith Exam".into(), style: Default::default() }).unwrap();
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines[0].text.trim(), "John");
|
||||
assert!((lines[0].rect[2] - lines[0].rect[0] - 25.0).abs() < 0.5, "John keeps its width: {:?}", lines[0].rect);
|
||||
}
|
||||
|
||||
/// With a new font the phrase is one run in its first word's text state. How wide it is must not
|
||||
/// depend on how far OCR misjudged that word's size: the same boxes give the same line.
|
||||
#[test]
|
||||
fn a_new_font_for_an_ocr_phrase_is_as_wide_whatever_its_first_word_was_sized() {
|
||||
let width = |first: f64| {
|
||||
let (mut s, id) = scanned_with(vec![
|
||||
scanned_word("John", 20.0, 45.0, 70.0, first),
|
||||
scanned_word("Smith", 49.0, 78.0, 70.0, 11.0),
|
||||
scanned_word("Example", 82.0, 124.0, 70.0, 11.0),
|
||||
]);
|
||||
let style = pdfcraft_engine::BlockStyle { family: Some((pdfcraft_engine::FontFamily::Helvetica, false, false)), ..Default::default() };
|
||||
// Short enough not to wrap in the phrase's box either way.
|
||||
s.apply(id, Edit::EditTextBlock { page: 0, block: 0, text: "John Smith".into(), style }).unwrap();
|
||||
let lines = s.get(id).unwrap().text_lines(0);
|
||||
assert_eq!(lines.len(), 1, "one line: {lines:?}");
|
||||
lines.iter().map(|l| l.rect[2]).fold(f64::MIN, f64::max) - lines.iter().map(|l| l.rect[0]).fold(f64::MAX, f64::min)
|
||||
};
|
||||
let (guessed, right) = (width(15.0), width(11.0));
|
||||
assert!((guessed - right).abs() < 0.5, "a 15 pt guess for John makes the line {guessed} wide, an 11 pt one {right}");
|
||||
}
|
||||
|
||||
/// Visible text set with a horizontally stretched text matrix (3 × 1), as some generators write.
|
||||
fn stretched_visible() -> std::sync::Arc<Vec<u8>> {
|
||||
use pdfcraft_cos::{Dict, Document, Object, SaveOptions, Stream};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::Zip;
|
||||
use super::as_html::esc;
|
||||
use super::as_html::{esc, language_tag};
|
||||
use super::layout::{Image, Item, Page, Table, body_size, items};
|
||||
|
||||
/// A colour's 8-bit sRGB components (NaN and out-of-range values clamp; NaN reads as 0).
|
||||
@@ -31,16 +31,19 @@ fn run_xml(text: &str, size: f64, bold: bool, italic: bool, color: [f64; 3]) ->
|
||||
format!("<w:r><w:rPr>{rpr}</w:rPr><w:t xml:space=\"preserve\">{}</w:t></w:r>", esc(text))
|
||||
}
|
||||
|
||||
/// One Word table: single borders when the page's rules draw its grid, explicit absent borders
|
||||
/// otherwise (alignment alone cannot prove PDF rules), a grid sized from the detected column
|
||||
/// One Word table: when the page's rules draw its grid, single inside borders and single outer
|
||||
/// borders on the edges they rule; explicit absent borders otherwise (alignment alone cannot
|
||||
/// prove PDF rules), a grid sized from the detected column
|
||||
/// edges, `gridSpan` for spanning cells, empty cells for holes.
|
||||
pub(crate) fn docx_table(t: &Table) -> String {
|
||||
let mut edges = t.cols.clone();
|
||||
edges.push(t.rect[2]);
|
||||
let widths: Vec<i64> = edges.windows(2).map(|w| ((w[1] - w[0]).max(20.0) * 20.0).round().clamp(60.0, 31680.0) as i64).collect();
|
||||
let mut s = String::from("<w:tbl><w:tblPr><w:tblW w:w=\"0\" w:type=\"auto\"/><w:tblBorders>");
|
||||
for edge in ["top", "left", "bottom", "right", "insideH", "insideV"] {
|
||||
if t.ruled {
|
||||
let [top, left, bottom, right] = t.ruled.unwrap_or_default();
|
||||
let inside = t.ruled.is_some();
|
||||
for (edge, drawn) in [("top", top), ("left", left), ("bottom", bottom), ("right", right), ("insideH", inside), ("insideV", inside)] {
|
||||
if drawn {
|
||||
s.push_str(&format!("<w:{edge} w:val=\"single\" w:sz=\"4\" w:space=\"0\" w:color=\"auto\"/>"));
|
||||
} else {
|
||||
s.push_str(&format!("<w:{edge} w:val=\"nil\"/>"));
|
||||
@@ -233,9 +236,40 @@ fn word_background(pages: &[Page]) -> Option<String> {
|
||||
(same && first != [255, 255, 255]).then(|| format!("{:02X}{:02X}{:02X}", first[0], first[1], first[2]))
|
||||
}
|
||||
|
||||
/// The `w:lang` that makes `tag` the language of a Word document's text. Word keeps one language
|
||||
/// per script: Chinese, Japanese and Korean are its East Asian text, Arabic, Hebrew, Persian and
|
||||
/// Urdu its right-to-left text, and any other language its Latin, Greek and Cyrillic text. A
|
||||
/// script the tag names (`pa-Arab`, `zh-Latn`) decides over its language.
|
||||
fn word_lang(tag: &str) -> String {
|
||||
let mut subtags = tag.split('-');
|
||||
let primary = subtags.next().unwrap_or_default().to_ascii_lowercase();
|
||||
// The script comes straight after the language and any three-letter extended languages; a
|
||||
// singleton before it starts an extension or private use, which names no script.
|
||||
let script = subtags
|
||||
.find(|s| s.len() != 3 || !s.bytes().all(|b| b.is_ascii_alphabetic()))
|
||||
.filter(|s| s.len() == 4 && s.bytes().all(|b| b.is_ascii_alphabetic()))
|
||||
.map(str::to_ascii_lowercase);
|
||||
let attribute = match (script.as_deref(), primary.as_str()) {
|
||||
(Some("arab" | "hebr" | "syrc" | "thaa"), _) => "w:bidi",
|
||||
(Some("hans" | "hant" | "hani" | "jpan" | "kore" | "hang" | "hira" | "kana"), _) => "w:eastAsia",
|
||||
(Some("latn" | "cyrl" | "grek"), _) => "w:val",
|
||||
(_, "zh" | "ja" | "ko") => "w:eastAsia",
|
||||
(_, "ar" | "he" | "fa" | "ur") => "w:bidi",
|
||||
_ => "w:val",
|
||||
};
|
||||
format!("<w:lang {attribute}=\"{}\"/>", esc(tag))
|
||||
}
|
||||
|
||||
/// A Word document (.docx, Office Open XML). Ordinary pages flow as paragraphs and tables;
|
||||
/// pages with rotated text, or text that overlaps artwork, use floating shapes to retain their layout.
|
||||
pub fn docx(pages: &[Page], title: &str) -> Vec<u8> {
|
||||
docx_with_language(pages, title, None)
|
||||
}
|
||||
|
||||
/// [`docx`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives
|
||||
/// it): the default language of its text, so Word checks the spelling in that language rather
|
||||
/// than in its own. A language that is not a well formed tag is left out.
|
||||
pub fn docx_with_language(pages: &[Page], title: &str, language: Option<&str>) -> Vec<u8> {
|
||||
let mut body = String::new();
|
||||
let mut media: Vec<(String, &Image)> = Vec::new();
|
||||
// Word needs a paragraph between adjacent tables and after the last one in the body.
|
||||
@@ -343,11 +377,16 @@ xmlns:wp=\"http://schemas.openxmlformats.org/drawingml/2006/wordprocessingDrawin
|
||||
doc_rels.push_str("<Relationship Id=\"rIdSettings\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings\" Target=\"settings.xml\"/>");
|
||||
}
|
||||
doc_rels.push_str("</Relationships>");
|
||||
let styles = "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">\
|
||||
let defaults = language_tag(language)
|
||||
.map(|tag| format!("<w:docDefaults><w:rPrDefault><w:rPr>{}</w:rPr></w:rPrDefault></w:docDefaults>", word_lang(tag)))
|
||||
.unwrap_or_default();
|
||||
let styles = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<w:styles xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\">{defaults}\
|
||||
<w:style w:type=\"paragraph\" w:default=\"1\" w:styleId=\"Normal\"><w:name w:val=\"Normal\"/><w:pPr><w:spacing w:after=\"120\"/></w:pPr></w:style>\
|
||||
<w:style w:type=\"paragraph\" w:styleId=\"Heading1\"><w:name w:val=\"heading 1\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"240\"/><w:outlineLvl w:val=\"0\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
|
||||
<w:style w:type=\"paragraph\" w:styleId=\"Heading2\"><w:name w:val=\"heading 2\"/><w:basedOn w:val=\"Normal\"/><w:next w:val=\"Normal\"/><w:pPr><w:keepNext/><w:spacing w:before=\"200\"/><w:outlineLvl w:val=\"1\"/></w:pPr><w:rPr><w:b/></w:rPr></w:style>\
|
||||
</w:styles>";
|
||||
</w:styles>"
|
||||
);
|
||||
let core = format!(
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?>\n<cp:coreProperties xmlns:cp=\"http://schemas.openxmlformats.org/package/2006/metadata/core-properties\" xmlns:dc=\"http://purl.org/dc/elements/1.1/\"><dc:title>{}</dc:title></cp:coreProperties>",
|
||||
esc(title)
|
||||
|
||||
@@ -8,6 +8,41 @@ pub(crate) fn allowed(c: char) -> bool {
|
||||
matches!(c, '\t' | '\n' | '\r') || !(c.is_ascii_control() || c == '\u{FFFE}' || c == '\u{FFFF}')
|
||||
}
|
||||
|
||||
/// The longest language tag the exports write. BCP 47 sets no maximum (35 characters is only
|
||||
/// the least a protocol must hold), so this bounds only what a PDF's `/Lang` can put in a file.
|
||||
const LONGEST_TAG: usize = 255;
|
||||
|
||||
/// `lang` when it is a well formed language tag (BCP 47, as a PDF's `/Lang` holds one): a primary
|
||||
/// language of two or three letters, then subtags of one to eight letters or digits, joined by
|
||||
/// hyphens, [`LONGEST_TAG`] characters at most. A singleton (one letter or digit) starts an
|
||||
/// extension, or with `x` private use, and needs a subtag after it. Anything else names no
|
||||
/// language.
|
||||
pub(crate) fn language_tag(lang: Option<&str>) -> Option<&str> {
|
||||
let tag = lang?.trim();
|
||||
let mut subtags = tag.split('-');
|
||||
let primary = subtags.next()?;
|
||||
if tag.len() > LONGEST_TAG || !(2..=3).contains(&primary.len()) || !primary.bytes().all(|b| b.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// In private use every subtag is data, one character long or not.
|
||||
let (mut private, mut singleton) = (false, false);
|
||||
for s in subtags {
|
||||
if !(1..=8).contains(&s.len()) || !s.bytes().all(|b| b.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
if s.len() == 1 && !private {
|
||||
if singleton {
|
||||
return None;
|
||||
}
|
||||
private = s.eq_ignore_ascii_case("x");
|
||||
singleton = true;
|
||||
} else {
|
||||
singleton = false;
|
||||
}
|
||||
}
|
||||
(!singleton).then_some(tag)
|
||||
}
|
||||
|
||||
pub(crate) fn esc(s: &str) -> String {
|
||||
let mut o = String::with_capacity(s.len());
|
||||
for c in s.chars().filter(|c| allowed(*c)) {
|
||||
@@ -38,17 +73,31 @@ pub(crate) fn base64(data: &[u8]) -> String {
|
||||
/// One HTML file: headings and paragraphs, tables as real `<table>` elements, images inline, a
|
||||
/// rule between pages.
|
||||
pub fn html(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, false)
|
||||
html_with_language(pages, title, None)
|
||||
}
|
||||
|
||||
/// A simple HTML export with no CSS; page layout and colours are omitted.
|
||||
pub fn html_simple(pages: &[Page], title: &str) -> String {
|
||||
html_with_mode(pages, title, true)
|
||||
html_simple_with_language(pages, title, None)
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool) -> String {
|
||||
/// [`html`] for a document in `language` (a BCP 47 tag such as `bg-BG`, as a PDF's `/Lang` gives
|
||||
/// it), which the page names (`<html lang>`) for spell checking, speech and hyphenation. A
|
||||
/// language that is not a well formed tag is left out.
|
||||
pub fn html_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
|
||||
html_with_mode(pages, title, false, language)
|
||||
}
|
||||
|
||||
/// [`html_simple`] for a document in `language`, named as [`html_with_language`] names it: a
|
||||
/// language is not styling, so the simple export keeps it.
|
||||
pub fn html_simple_with_language(pages: &[Page], title: &str, language: Option<&str>) -> String {
|
||||
html_with_mode(pages, title, true, language)
|
||||
}
|
||||
|
||||
fn html_with_mode(pages: &[Page], title: &str, simple: bool, language: Option<&str>) -> String {
|
||||
let lang = language_tag(language).map(|tag| format!(" lang=\"{}\"", esc(tag))).unwrap_or_default();
|
||||
let mut s = format!(
|
||||
"<!DOCTYPE html>\n<html>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
|
||||
"<!DOCTYPE html>\n<html{lang}>\n<head>\n<meta charset=\"utf-8\">\n<title>{}</title>\n{}\n</head>\n<body>\n",
|
||||
esc(title),
|
||||
if simple {
|
||||
""
|
||||
|
||||
+47
-11
@@ -68,9 +68,10 @@ pub struct Table {
|
||||
pub rect: [f64; 4],
|
||||
pub cols: Vec<f64>,
|
||||
pub rows: Vec<Vec<Cell>>,
|
||||
/// Whether the page's ruled lines separate every pair of neighbouring cells, so the writers
|
||||
/// can draw the table's borders.
|
||||
pub ruled: bool,
|
||||
/// `Some` when the page's ruled lines separate every pair of neighbouring cells, so the
|
||||
/// writers draw the inside borders; it holds which outer edges (top, left, bottom, right) the
|
||||
/// lines also draw, as only those get a border.
|
||||
pub ruled: Option<[bool; 4]>,
|
||||
}
|
||||
|
||||
/// What goes into the output, in order.
|
||||
@@ -264,8 +265,11 @@ const RULE_BUDGET: usize = 4_000_000;
|
||||
/// Whether `rules` separate every pair of neighbouring cells of a table whose rows are `rows`
|
||||
/// (block indices, top to bottom) and whose columns start at `edges` and end at `right`: between
|
||||
/// two columns a vertical rule beside each row (unless a cell of that row spans across), and
|
||||
/// between two rows a horizontal rule under each column. Every check spends `budget`.
|
||||
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> bool {
|
||||
/// between two rows a horizontal rule under each column. Every check spends `budget`. When they
|
||||
/// do, says which outer edges (top, left, bottom, right) are ruled too: a rule over every column
|
||||
/// above the first row or below the last, or beside every row left of the first column or right
|
||||
/// of the last, no farther from the cells than the next row or column is.
|
||||
fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64], right: f64, budget: &mut usize) -> Option<[bool; 4]> {
|
||||
let vertical: Vec<&[f64; 4]> = rules.iter().filter(|r| r[2] - r[0] <= RULE_THICK && r[3] - r[1] > RULE_THICK).collect();
|
||||
let horizontal: Vec<&[f64; 4]> = rules.iter().filter(|r| r[3] - r[1] <= RULE_THICK && r[2] - r[0] > RULE_THICK).collect();
|
||||
let mut find = |set: &[&[f64; 4]], hit: &dyn Fn(&[f64; 4]) -> bool| -> bool {
|
||||
@@ -295,7 +299,7 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64
|
||||
let x = (r[0] + r[2]) / 2.0;
|
||||
lo + COL_TOL < x && x <= hi + COL_TOL && r[1] <= mid && mid <= r[3] && clear(x)
|
||||
}) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -307,11 +311,41 @@ fn ruled(blocks: &[Block], rules: &[[f64; 4]], rows: &[Vec<usize>], edges: &[f64
|
||||
let y = (r[1] + r[3]) / 2.0;
|
||||
below_top - COL_TOL <= y && y <= above_bottom + COL_TOL && r[0] <= x && x <= r[2]
|
||||
}) {
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
let (Some(first), Some(last), Some(&left), Some(&last_col)) = (spans.first(), spans.last(), edges.first(), edges.last()) else {
|
||||
return Some([false; 4]);
|
||||
};
|
||||
// How far the next row or column is: an outer rule farther out than that frames something else.
|
||||
let row_gap = |a: Option<&[f64; 2]>, b: Option<&[f64; 2]>, side: usize| a.zip(b).map_or(0.0, |(a, b)| (a[side] - b[side]).abs());
|
||||
let top_gap = row_gap(Some(first), spans.get(1), 1);
|
||||
let bottom_gap = row_gap(spans.len().checked_sub(2).and_then(|i| spans.get(i)), Some(last), 0);
|
||||
let left_gap = edges.get(1).map_or(0.0, |e| e - left);
|
||||
let right_gap = edges.len().checked_sub(2).and_then(|i| edges.get(i)).map_or(0.0, |e| last_col - e);
|
||||
let mut over_columns = |lo: f64, hi: f64| {
|
||||
mids.iter().all(|&x| {
|
||||
find(&horizontal, &|r| {
|
||||
let y = (r[1] + r[3]) / 2.0;
|
||||
lo <= y && y <= hi && r[0] <= x && x <= r[2]
|
||||
})
|
||||
})
|
||||
};
|
||||
let top = over_columns(first[1] - COL_TOL, first[1] + top_gap + COL_TOL);
|
||||
let bottom = over_columns(last[0] - bottom_gap - COL_TOL, last[0] + COL_TOL);
|
||||
let mut beside_rows = |lo: f64, hi: f64| {
|
||||
spans.iter().all(|span| {
|
||||
let mid = (span[0] + span[1]) / 2.0;
|
||||
find(&vertical, &|r| {
|
||||
let x = (r[0] + r[2]) / 2.0;
|
||||
lo <= x && x <= hi && r[1] <= mid && mid <= r[3]
|
||||
})
|
||||
})
|
||||
};
|
||||
let left_ruled = beside_rows(left - left_gap - COL_TOL, left + COL_TOL);
|
||||
let right_ruled = beside_rows(right - COL_TOL, right.max(last_col + right_gap) + COL_TOL);
|
||||
Some([top, left_ruled, bottom, right_ruled])
|
||||
}
|
||||
|
||||
/// Detect tables in a page's blocks from their text layout: columns are left edges shared by
|
||||
@@ -394,11 +428,13 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
|
||||
edges.dedup_by(|a, b| (*a - *b).abs() <= COL_TOL);
|
||||
let ncols = edges.len();
|
||||
let right = used.iter().map(|&i| blocks[i].rect[2]).fold(f64::MIN, f64::max) + COL_TOL;
|
||||
let is_ruled = (2..=MAX_COLS).contains(&ncols) && !rules.is_empty() && {
|
||||
let frame = if (2..=MAX_COLS).contains(&ncols) && !rules.is_empty() {
|
||||
let row_blocks: Vec<Vec<usize>> = run.iter().map(|row| row.iter().map(|(_, i)| *i).collect()).collect();
|
||||
ruled(blocks, rules, &row_blocks, &edges, right, budget)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || is_ruled))) && ncols <= MAX_COLS;
|
||||
let ok = (ncols >= 3 || (ncols == 2 && (run.len() >= 3 || frame.is_some()))) && ncols <= MAX_COLS;
|
||||
if !ok {
|
||||
run.clear();
|
||||
return;
|
||||
@@ -457,7 +493,7 @@ pub(crate) fn tables(blocks: &[Block], rules: &[[f64; 4]]) -> (Vec<Table>, Vec<b
|
||||
right,
|
||||
used.iter().map(|&i| blocks[i].rect[3]).fold(f64::MIN, f64::max),
|
||||
];
|
||||
out.push(Table { rect, cols: edges, rows: rows_out, ruled: is_ruled });
|
||||
out.push(Table { rect, cols: edges, rows: rows_out, ruled: frame });
|
||||
};
|
||||
// Runs of consecutive multi-column rows are tables. Rows on either side of a run that
|
||||
// hold a single cell wide enough to span two columns (a header band, a totals row) join
|
||||
|
||||
@@ -23,14 +23,14 @@ mod as_rtf;
|
||||
mod clip;
|
||||
mod layout;
|
||||
|
||||
pub use as_docx::docx;
|
||||
pub(crate) use as_docx::hex;
|
||||
#[cfg(test)]
|
||||
use as_docx::{MAX_MEDIA, docx_table};
|
||||
pub use as_docx::{docx, docx_with_language};
|
||||
pub(crate) use as_html::esc;
|
||||
#[cfg(test)]
|
||||
use as_html::{allowed, base64};
|
||||
pub use as_html::{html, html_simple};
|
||||
pub use as_html::{html, html_simple, html_simple_with_language, html_with_language};
|
||||
pub use as_markdown::markdown;
|
||||
#[cfg(test)]
|
||||
use as_markdown::{emphasis, paragraph};
|
||||
|
||||
@@ -137,7 +137,7 @@ fn word_table_cell_widths_follow_the_columns_they_span() {
|
||||
vec![cell("All", 3)],
|
||||
vec![cell("First", 1)],
|
||||
],
|
||||
ruled: false,
|
||||
ruled: None,
|
||||
};
|
||||
let xml = docx_table(&table);
|
||||
assert!(xml.contains("<w:gridCol w:w=\"1000\"/><w:gridCol w:w=\"2000\"/><w:gridCol w:w=\"3000\"/>"), "{xml}");
|
||||
@@ -247,6 +247,61 @@ pub(crate) fn part(zip: &[u8], name: &str) -> String {
|
||||
panic!("{name} missing")
|
||||
}
|
||||
|
||||
/// The PDF's language (`/Lang`) is the default language of the Word document's text, in the
|
||||
/// attribute Word keeps for its script, and the HTML page's. No language, or one that is not a
|
||||
/// well formed tag, leaves both files as they were.
|
||||
#[test]
|
||||
fn word_and_html_take_the_documents_language() {
|
||||
let pages = [page()];
|
||||
let styles = |lang| part(&docx_with_language(&pages, "Report", lang), "word/styles.xml");
|
||||
let defaults = |lang: &str| format!("<w:docDefaults><w:rPrDefault><w:rPr>{lang}</w:rPr></w:rPrDefault></w:docDefaults>");
|
||||
assert!(styles(Some("bg-BG")).contains(&defaults("<w:lang w:val=\"bg-BG\"/>")), "{}", styles(Some("bg-BG")));
|
||||
assert!(styles(Some(" en-GB ")).contains("<w:lang w:val=\"en-GB\"/>"), "the tag is trimmed");
|
||||
assert!(styles(Some("zh-Hant-TW")).contains(&defaults("<w:lang w:eastAsia=\"zh-Hant-TW\"/>")), "East Asian text");
|
||||
assert!(styles(Some("ja")).contains("<w:lang w:eastAsia=\"ja\"/>"), "East Asian text");
|
||||
assert!(styles(Some("ar-EG")).contains(&defaults("<w:lang w:bidi=\"ar-EG\"/>")), "right-to-left text");
|
||||
assert!(styles(Some("HE")).contains("<w:lang w:bidi=\"HE\"/>"), "right-to-left text");
|
||||
// A script subtag decides over the language; private use names no script.
|
||||
assert!(styles(Some("pa-Arab-PK")).contains("<w:lang w:bidi=\"pa-Arab-PK\"/>"), "Punjabi in Arabic script");
|
||||
assert!(styles(Some("sr-Latn")).contains("<w:lang w:val=\"sr-Latn\"/>"), "Serbian in Latin script");
|
||||
assert!(styles(Some("zh-Latn")).contains("<w:lang w:val=\"zh-Latn\"/>"), "Chinese in Latin script");
|
||||
assert!(styles(Some("en-x-arab")).contains("<w:lang w:val=\"en-x-arab\"/>"), "private use");
|
||||
assert!(styles(Some("az-Latn-x-arab")).contains("<w:lang w:val=\"az-Latn-x-arab\"/>"), "private use");
|
||||
assert!(html_with_language(&pages, "Report", Some("bg-BG")).starts_with("<!DOCTYPE html>\n<html lang=\"bg-BG\">\n"));
|
||||
// A tag longer than 35 characters is still a tag.
|
||||
let long = "en-US-u-ca-gregory-nu-latn-co-phonebk";
|
||||
assert!(styles(Some(long)).contains(&format!("<w:lang w:val=\"{long}\"/>")), "{long}");
|
||||
assert!(html_with_language(&pages, "Report", Some(long)).contains(&format!("<html lang=\"{long}\">")), "{long}");
|
||||
let too_long = format!("en{}", "-abcdefgh".repeat(29));
|
||||
// Private use takes one-character subtags.
|
||||
assert!(html_with_language(&pages, "Report", Some("en-x-a-b")).contains("<html lang=\"en-x-a-b\">"));
|
||||
for lang in [
|
||||
None,
|
||||
Some(""),
|
||||
Some(" "),
|
||||
Some("bg_BG"),
|
||||
Some("b"),
|
||||
Some("bulg-BG"),
|
||||
Some("bg--BG"),
|
||||
Some("bg-"),
|
||||
Some("en-x"),
|
||||
Some("en-a"),
|
||||
Some("en-a-x-private"),
|
||||
Some("en-US-u-ca-x"),
|
||||
Some("en-US-abcdefghi"),
|
||||
Some(too_long.as_str()),
|
||||
Some("en-\"x\""),
|
||||
Some("1a-BG"),
|
||||
Some("\u{431}\u{433}-BG"),
|
||||
] {
|
||||
assert!(docx_with_language(&pages, "Report", lang) == docx(&pages, "Report"), "{lang:?}");
|
||||
assert_eq!(html_with_language(&pages, "Report", lang), html(&pages, "Report"), "{lang:?}");
|
||||
assert_eq!(html_simple_with_language(&pages, "Report", lang), html_simple(&pages, "Report"), "{lang:?}");
|
||||
}
|
||||
// The simple export leaves out styling, not the language.
|
||||
assert!(html_simple_with_language(&pages, "Report", Some("bg-BG")).starts_with("<!DOCTYPE html>\n<html lang=\"bg-BG\">\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn word_opens_text_with_control_characters_and_long_pages() {
|
||||
// #72: text extracted without a usable /ToUnicode holds C0 controls, which XML forbids;
|
||||
@@ -887,7 +942,7 @@ fn a_ruled_two_by_two_grid_is_a_table() {
|
||||
];
|
||||
let (found, consumed) = tables(&blocks, &grid);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert!(found[0].ruled);
|
||||
assert_eq!(found[0].ruled, Some([true; 4]), "the grid's frame is ruled on every side");
|
||||
assert_eq!(found[0].rows.len(), 2);
|
||||
assert!(consumed.iter().all(|used| *used));
|
||||
let p = Page { width: 320.0, height: 300.0, blocks: blocks.clone(), rules: grid, ..Page::default() };
|
||||
@@ -899,3 +954,32 @@ fn a_ruled_two_by_two_grid_is_a_table() {
|
||||
let underlines = [[20.0, 245.0, 300.0, 245.0]];
|
||||
assert!(tables(&blocks, &underlines).0.is_empty(), "a rule between the rows alone is no grid");
|
||||
}
|
||||
|
||||
/// A grid drawn with inside lines only gets inside borders in Word, not a box the page never
|
||||
/// draws; an outer line far from the table (a page rule) is not its frame.
|
||||
#[test]
|
||||
fn a_ruled_table_gets_outer_borders_only_where_the_page_draws_them() {
|
||||
let blocks = vec![
|
||||
block("Name", 25.0, 250.0, 55.0, 261.0),
|
||||
block("Value", 165.0, 250.0, 195.0, 261.0),
|
||||
block("Alpha", 25.0, 225.0, 55.0, 236.0),
|
||||
block("One", 165.0, 225.0, 185.0, 236.0),
|
||||
];
|
||||
let inside = vec![[20.0, 245.0, 300.0, 245.0], [160.0, 220.0, 160.0, 270.0]];
|
||||
let (found, _) = tables(&blocks, &inside);
|
||||
assert_eq!(found.len(), 1);
|
||||
assert_eq!(found[0].ruled, Some([false; 4]));
|
||||
let xml = docx_table(&found[0]);
|
||||
for edge in ["insideH", "insideV"] {
|
||||
assert!(xml.contains(&format!("<w:{edge} w:val=\"single\"")), "{xml}");
|
||||
}
|
||||
for edge in ["top", "left", "bottom", "right"] {
|
||||
assert!(xml.contains(&format!("<w:{edge} w:val=\"nil\"/>")), "{xml}");
|
||||
}
|
||||
|
||||
let mut ruled_top = inside.clone();
|
||||
ruled_top.push([20.0, 270.0, 300.0, 270.0]);
|
||||
ruled_top.push([0.0, 500.0, 320.0, 500.0]);
|
||||
ruled_top.push([0.0, 0.0, 320.0, 0.0]);
|
||||
assert_eq!(tables(&blocks, &ruled_top).0[0].ruled, Some([true, false, false, false]), "only the top line near the table frames it");
|
||||
}
|
||||
|
||||
@@ -86,3 +86,11 @@ Document font inputs use `embedding`: bounded static TrueType/OpenType-CFF valid
|
||||
exact program preservation, Unicode/CID/GID mapping and full-face subset completion.
|
||||
`shaping` uses the same program with harfrust and UAX #9; source UI fallback fonts are
|
||||
never exported. See [existing-text editing](../../docs/editing-existing-text.md).
|
||||
|
||||
`pdf::embedded_sfnt_within` reconstructs a missing Unicode cmap for Identity-H TrueType
|
||||
subsets from the PDF's ToUnicode and CID-to-glyph maps, and completes a THSarabunPSK subset's
|
||||
Latin-only one with them. Only the in-memory editor copy changes;
|
||||
the PDF's font program stays intact. `pdf::LegacyThaiFont` shapes verified THSarabunPSK
|
||||
positional glyphs into their original PDF codes, retaining the source face and weight. Missing
|
||||
contextual glyphs are refused. `pdf::recovered_thai_map` recognizes narrowly fingerprinted
|
||||
damaged exporter maps; callers must record repairs and apply them to page-owned font copies.
|
||||
|
||||
+105
-19
@@ -108,30 +108,49 @@ pub fn embedded_sfnt_within(doc: &Document, font: &Dict, budget: &mut DecodeBudg
|
||||
let Some(object) = descriptor.get(key) else { continue };
|
||||
let object = doc.resolve(object);
|
||||
let Object::Stream(stream) = object.as_ref() else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(stream, MAX_SFNT) else { continue };
|
||||
let Ok(mut bytes) = budget.decode_within(&doc.with_direct_parms(stream), MAX_SFNT) else { continue };
|
||||
let Ok(face) = skrifa::FontRef::from_index(&bytes, 0) else { continue };
|
||||
if face.metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default()).units_per_em == 0 {
|
||||
continue;
|
||||
}
|
||||
if face.charmap().mappings().next().is_some() {
|
||||
let own_cmap = face.charmap().mappings().next().is_some();
|
||||
// A legacy THSarabunPSK subset may keep a Latin-only cmap beside its Thai glyphs, which
|
||||
// only the PDF's verified maps reach (`composite_glyph_maps`); other programs keep theirs.
|
||||
let base_font = font.name(b"BaseFont").map(String::from_utf8_lossy).unwrap_or_default();
|
||||
if own_cmap && !base_font.rsplit('+').next().is_some_and(|f| f.starts_with("THSarabunPSK")) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// Some PDF generators keep a Mac format-6 code→glyph cmap. egui accepts Unicode
|
||||
// format-4/12 cmaps. Combine the PDF's Unicode meanings with the source glyph IDs;
|
||||
// only this in-memory copy gets a new cmap, never the working PDF or a font asset.
|
||||
let Ok(cmap) = face.cmap() else { continue };
|
||||
// PDF subsets may remove the Unicode cmap entirely. Rebuild an editor-only
|
||||
// cmap from ToUnicode and the actual CID-to-glyph map; source bytes stay unchanged.
|
||||
let metrics = Metrics::from_dict(doc, font);
|
||||
if metrics.composite {
|
||||
continue;
|
||||
}
|
||||
let mut mappings = std::collections::BTreeMap::new();
|
||||
for code in 0..=255 {
|
||||
let Some(text) = metrics.unicode.text(code) else { continue };
|
||||
let Some(ch) = only_char(&text) else { continue };
|
||||
let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue };
|
||||
let glyph = glyph.to_u32();
|
||||
if glyph > 0 {
|
||||
mappings.entry(u32::from(ch)).or_insert(glyph);
|
||||
if metrics.composite {
|
||||
if let Some((map, _)) = composite_glyph_maps(doc, font, &bytes, &metrics, budget) {
|
||||
mappings = map;
|
||||
}
|
||||
if own_cmap {
|
||||
let charmap = face.charmap();
|
||||
if mappings.keys().all(|&u| char::from_u32(u).is_some_and(|c| charmap.map(c).is_some())) {
|
||||
return Some(bytes);
|
||||
}
|
||||
// The program's own mappings stay; the PDF's fill only what they lack.
|
||||
mappings.extend(charmap.mappings().filter(|(_, g)| g.to_u32() != 0).take(65_537).map(|(u, g)| (u, g.to_u32())));
|
||||
let mut merged = bytes.clone();
|
||||
let usable = append_editor_cmap(&mut merged, &mappings).is_some()
|
||||
&& skrifa::FontRef::new(&merged).is_ok_and(|font| font.charmap().mappings().next().is_some());
|
||||
return Some(if usable { merged } else { bytes });
|
||||
}
|
||||
} else if own_cmap {
|
||||
return Some(bytes);
|
||||
} else if let Ok(cmap) = face.cmap() {
|
||||
for code in 0..=255 {
|
||||
let Some(text) = metrics.unicode.text(code) else { continue };
|
||||
let Some(ch) = only_char(&text) else { continue };
|
||||
let Some(glyph) = cmap.map_codepoint(code).or_else(|| cmap.map_codepoint(code + 0xf000)) else { continue };
|
||||
let glyph = glyph.to_u32();
|
||||
if glyph > 0 {
|
||||
mappings.entry(u32::from(ch)).or_insert(glyph);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !mappings.is_empty()
|
||||
@@ -214,14 +233,14 @@ fn embedded_widths(doc: &Document, font: &Dict, m: &mut Metrics) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace an existing cmap directory entry with a bounded Unicode format-12 table.
|
||||
/// Add or replace an editor-only cmap with a bounded Unicode format-12 table.
|
||||
/// Checked ranges prevent damaged sfnt offsets from indexing outside the source program.
|
||||
fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap<u32, u32>) -> Option<()> {
|
||||
if mappings.len() > 65536 {
|
||||
return None;
|
||||
}
|
||||
let count = u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?) as usize;
|
||||
if count > 256 {
|
||||
if count == 0 || count > 256 {
|
||||
return None;
|
||||
}
|
||||
let mut entry = None;
|
||||
@@ -236,7 +255,10 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
head = Some(u32::from_be_bytes(record.get(8..12)?.try_into().ok()?) as usize);
|
||||
}
|
||||
}
|
||||
let entry = entry?;
|
||||
let (entry, head) = match entry {
|
||||
Some(entry) => (entry, head),
|
||||
None => insert_editor_cmap_slot(bytes, count, head)?,
|
||||
};
|
||||
let mut cmap = Vec::with_capacity(28 + mappings.len() * 12);
|
||||
cmap.extend_from_slice(&[0, 0, 0, 1, 0, 3, 0, 10, 0, 0, 0, 12, 0, 12, 0, 0]);
|
||||
cmap.extend_from_slice(&(16_u32 + u32::try_from(mappings.len()).ok()? * 12).to_be_bytes());
|
||||
@@ -264,6 +286,17 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
bytes.get_mut(entry + 8..entry + 12)?.copy_from_slice(&offset.to_be_bytes());
|
||||
bytes.get_mut(entry + 12..entry + 16)?.copy_from_slice(&u32::try_from(cmap.len()).ok()?.to_be_bytes());
|
||||
bytes.extend_from_slice(&cmap);
|
||||
// SFNT directories are sorted by tag. Some readers tolerate the unsorted directory of
|
||||
// a PDF subset, but shaping engines may binary-search it and otherwise miss the cmap.
|
||||
// Adding a slot increased the directory count; include the new record in this sort.
|
||||
let tables = usize::from(u16::from_be_bytes(bytes.get(4..6)?.try_into().ok()?));
|
||||
let end = 12usize.checked_add(tables.checked_mul(16)?)?;
|
||||
let mut records: Vec<[u8; 16]> = bytes.get(12..end)?.as_chunks::<16>().0.to_vec();
|
||||
records.sort_by_key(|r| [r[0], r[1], r[2], r[3]]);
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
let at = 12 + index * 16;
|
||||
bytes.get_mut(at..at + 16)?.copy_from_slice(record);
|
||||
}
|
||||
if let Some(head) = head {
|
||||
bytes.get_mut(head.checked_add(8)?..head.checked_add(12)?)?.fill(0);
|
||||
let adjustment = 0xb1b0afba_u32.wrapping_sub(checksum(bytes));
|
||||
@@ -272,6 +305,42 @@ fn append_editor_cmap(bytes: &mut Vec<u8>, mappings: &std::collections::BTreeMap
|
||||
Some(())
|
||||
}
|
||||
|
||||
/// Reserve a new cmap directory slot, retaining every original table payload.
|
||||
fn insert_editor_cmap_slot(bytes: &mut Vec<u8>, mut count: usize, mut head: Option<usize>) -> Option<(usize, Option<usize>)> {
|
||||
if count >= 256 {
|
||||
return None;
|
||||
}
|
||||
let end = 12usize.checked_add(count.checked_mul(16)?)?;
|
||||
bytes.get(..end)?;
|
||||
// Keep all original table bytes, shifting them by one directory record.
|
||||
for i in 0..count {
|
||||
let at = 12 + i * 16;
|
||||
let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?);
|
||||
let len = u32::from_be_bytes(bytes.get(at + 12..at + 16)?.try_into().ok()?);
|
||||
if (offset as usize) < end {
|
||||
return None;
|
||||
}
|
||||
bytes.get(offset as usize..offset.checked_add(len)? as usize)?;
|
||||
}
|
||||
bytes.splice(end..end, [0; 16]);
|
||||
for i in 0..count {
|
||||
let at = 12 + i * 16;
|
||||
let offset = u32::from_be_bytes(bytes.get(at + 8..at + 12)?.try_into().ok()?).checked_add(16)?;
|
||||
bytes.get_mut(at + 8..at + 12)?.copy_from_slice(&offset.to_be_bytes());
|
||||
}
|
||||
head = head.and_then(|offset| offset.checked_add(16));
|
||||
bytes.get_mut(end..end + 4)?.copy_from_slice(b"cmap");
|
||||
count += 1;
|
||||
let count = u16::try_from(count).ok()?;
|
||||
let selector = count.ilog2() as u16;
|
||||
let range = (1u16.checked_shl(u32::from(selector))?).checked_mul(16)?;
|
||||
bytes.get_mut(4..6)?.copy_from_slice(&count.to_be_bytes());
|
||||
bytes.get_mut(6..8)?.copy_from_slice(&range.to_be_bytes());
|
||||
bytes.get_mut(8..10)?.copy_from_slice(&selector.to_be_bytes());
|
||||
bytes.get_mut(10..12)?.copy_from_slice(&count.checked_mul(16)?.checked_sub(range)?.to_be_bytes());
|
||||
Some((end, head))
|
||||
}
|
||||
|
||||
fn descriptor_flags(doc: &Document, descriptor: &Dict) -> u32 {
|
||||
let value = descriptor.get(b"Flags").and_then(|v| doc.resolve(v).as_f64()).unwrap_or(0.0) as i64;
|
||||
u32::try_from(value).unwrap_or(0)
|
||||
@@ -887,6 +956,19 @@ impl Metrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the embedded program draws an outline for `code`. Known (`Some`) only for a subset
|
||||
/// Type 0 font over a readable TrueType program, where a dropped glyph is left empty; `None`
|
||||
/// when the program can't tell, for example a simple or CFF font. [`Metrics::encode`] accepts
|
||||
/// any code with a `/W` entry (#1443), so a caller that would rather use another subset of the
|
||||
/// same face than draw nothing asks this too. An empty space glyph is normal.
|
||||
pub fn has_outline(&self, code: u32) -> Option<bool> {
|
||||
if !self.composite || self.outlined_cids.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cid = self.cid(code);
|
||||
Some(self.outlined_cids.get(cid as usize / 64).is_some_and(|w| (*w >> (cid % 64)) & 1 == 1))
|
||||
}
|
||||
|
||||
/// Whether `code` shows exactly `c` and has a glyph for it.
|
||||
fn shows(&self, code: u32, c: char) -> bool {
|
||||
self.unicode.text(code).is_some_and(|t| t.chars().eq(std::iter::once(c))) && self.has_glyph(code)
|
||||
@@ -1510,3 +1592,7 @@ mod encoding_tests {
|
||||
assert_eq!(budget.remaining(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
mod thai;
|
||||
use thai::composite_glyph_maps;
|
||||
pub use thai::{ExistingThaiRun, LegacyThaiFont, recovered_thai_map};
|
||||
|
||||
@@ -892,3 +892,44 @@ fn low_codes_resolve_to_the_covering_range() {
|
||||
assert_eq!(covered, [5, 6, 7, 100, 101]);
|
||||
assert_eq!((u.covering(0xFFFF).is_some(), u.covering(0x1_0001).is_some(), u.covering(0x1_0002).is_none()), (true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composite_subset_without_cmap_uses_pdf_glyph_ids_in_editor_and_shaper_without_replacing_the_program() {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let original = include_bytes!("../../../../assets/fonts/Inter-Regular.ttf");
|
||||
let expected = skrifa::FontRef::new(original).unwrap().charmap().map('Ω').unwrap();
|
||||
let mut subset = original.to_vec();
|
||||
let count = u16::from_be_bytes(subset[4..6].try_into().unwrap()) as usize;
|
||||
let records: Vec<_> = subset[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|r| &r[..4] != b"cmap").map(|r| r.to_vec()).collect();
|
||||
subset[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
for (index, record) in records.iter().enumerate() {
|
||||
subset[12 + index * 16..28 + index * 16].copy_from_slice(record);
|
||||
}
|
||||
assert!(skrifa::FontRef::new(&subset).unwrap().cmap().is_err());
|
||||
let mut doc = Document::new_empty();
|
||||
let program = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &subset)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), Object::Ref(program));
|
||||
let mut gids = vec![0; 36];
|
||||
gids[34..36].copy_from_slice(&(expected.to_u32() as u16).to_be_bytes());
|
||||
let gids = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), &gids)));
|
||||
let mut descendant = Dict::new();
|
||||
descendant.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
descendant.set(b"CIDToGIDMap".to_vec(), Object::Ref(gids));
|
||||
let unicode = doc.add(Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <0011> <03a9> endbfchar")));
|
||||
let source = font(
|
||||
&mut doc,
|
||||
vec![
|
||||
("Subtype", Object::name("Type0")),
|
||||
("Encoding", Object::name("Identity-H")),
|
||||
("DescendantFonts", Object::Array(vec![Object::Dict(descendant)])),
|
||||
("ToUnicode", Object::Ref(unicode)),
|
||||
],
|
||||
);
|
||||
let editor = embedded_sfnt(&doc, &source).expect("the source outlines must be usable in the editor");
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('Ω'), Some(expected));
|
||||
let shaped = crate::source_shaping::shape(&editor, "Ω").expect("the rebuilt cmap must work in the actual shaping engine too");
|
||||
assert_eq!(shaped.glyphs[0].id, expected);
|
||||
let Object::Stream(saved) = doc.get(program).as_ref().clone() else { panic!("fixture") };
|
||||
assert_eq!(saved.decoded().unwrap(), subset, "the source PDF font program stays byte-for-byte intact");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,734 @@
|
||||
//! Recover a narrowly identified legacy Thai exporter map, not arbitrary missing Unicode.
|
||||
//!
|
||||
//! Some THSarabunPSK Identity-H subsets retain original glyph IDs but write Latin/control
|
||||
//! destinations for Thai in ToUnicode (including an accidental 00ff..01ff range). The intact
|
||||
//! Thai anchors identify the original nominal Thai order. Only that complete fingerprint is
|
||||
//! accepted, and then only when every glyph the subset kept advances as its assumed character
|
||||
//! does (a mark takes none); correct maps and unrelated fonts are never changed. No font
|
||||
//! artwork is bundled.
|
||||
|
||||
use super::{Dict, Document, Metrics, dict, only_char, unicode_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Object};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Recover the nominal Thai codes and the presentation marks of this damaged legacy export.
|
||||
/// The caller must record the repair, and attach the map to a copy of the page's font.
|
||||
pub fn recovered_thai_map(doc: &Document, font: &Dict) -> Option<Vec<(u16, char)>> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
let base = String::from_utf8_lossy(font.name(b"BaseFont")?);
|
||||
let family = base.rsplit('+').next()?;
|
||||
if !matches!(family, "THSarabunPSK" | "THSarabunPSK-Bold" | "THSarabunPSK-Italic" | "THSarabunPSK-BoldItalic")
|
||||
|| font.name(b"Subtype") != Some(b"Type0")
|
||||
|| font.name(b"Encoding") != Some(b"Identity-H")
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let desc = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
if desc.name(b"CIDToGIDMap") != Some(b"Identity") {
|
||||
return None;
|
||||
}
|
||||
let map = unicode_map(doc, font, true);
|
||||
// Five separated consonants and three contextual marks must agree, together with the
|
||||
// corrupt destinations and corrupt range. This is deliberately stricter than font-name
|
||||
// detection: a renamed/subsetted font with different glyph order must not be guessed.
|
||||
let fingerprint = [
|
||||
(217, '\u{0e04}'),
|
||||
(226, '\u{0e0d}'),
|
||||
(250, '\u{0e25}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
(214, '\u{000f}'),
|
||||
(240, ')'),
|
||||
(248, '#'),
|
||||
(261, '>'),
|
||||
(263, '@'),
|
||||
(265, '>'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
];
|
||||
let matches = |pairs: &[(u32, char)], last: u16| {
|
||||
pairs.iter().all(|&(code, ch)| map.text(code).is_some_and(|t| t.chars().eq([ch])))
|
||||
&& map.ranges.iter().any(|r| r.lo == 255 && r.hi == 511 && r.units.as_ref() == [last])
|
||||
};
|
||||
// The same exporter emitted three distinct subset maps. Each must match its entire
|
||||
// intact-anchor and corrupt-destination fingerprint, including the oversized range.
|
||||
let body = matches(&fingerprint, 0x32);
|
||||
let attachment = matches(
|
||||
&[
|
||||
(217, '\u{0e04}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(297, '\u{0e58}'),
|
||||
(347, '\u{0e48}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
(214, '\u{000f}'),
|
||||
(226, '\u{000f}'),
|
||||
(240, '\u{0027}'),
|
||||
(248, '/'),
|
||||
(250, '1'),
|
||||
(265, 'B'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
],
|
||||
0x32,
|
||||
);
|
||||
let bold = matches(
|
||||
&[
|
||||
(217, '\u{0e04}'),
|
||||
(226, '\u{0e0d}'),
|
||||
(232, '\u{0e13}'),
|
||||
(234, '\u{0e15}'),
|
||||
(236, '\u{0e17}'),
|
||||
(243, '\u{0e1e}'),
|
||||
(250, '\u{0e25}'),
|
||||
(256, '\u{0e2b}'),
|
||||
(258, '\u{0e2d}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(214, '\u{000f}'),
|
||||
(240, '!'),
|
||||
(248, '1'),
|
||||
(263, '@'),
|
||||
(273, 'N'),
|
||||
(352, 'N'),
|
||||
(353, 'O'),
|
||||
],
|
||||
0x30,
|
||||
);
|
||||
if !(body || attachment || bold) {
|
||||
return None;
|
||||
}
|
||||
let nominal = (214u16..=298).filter_map(|gid| {
|
||||
let unicode = u32::from(gid) + if gid <= 271 { 0xd2b } else { 0xd2f };
|
||||
char::from_u32(unicode).map(|ch| (gid, ch))
|
||||
});
|
||||
// Contextual marks have the same character as the nominal mark, with a moved outline.
|
||||
let contextual = [
|
||||
(343, '\u{0e34}'),
|
||||
(344, '\u{0e35}'),
|
||||
(347, '\u{0e48}'),
|
||||
(351, '\u{0e4c}'),
|
||||
(352, '\u{0e48}'),
|
||||
(353, '\u{0e49}'),
|
||||
(356, '\u{0e4c}'),
|
||||
(358, '\u{0e31}'),
|
||||
(360, '\u{0e47}'),
|
||||
];
|
||||
// The anchors identify the exporter; the font program confirms each glyph's advance class
|
||||
// (not its identity, which nothing in such a subset records). Every glyph the
|
||||
// subset kept must advance as the character it is given (a vowel or tone mark set on its
|
||||
// base takes no advance, every other character does), else the order differs somewhere and
|
||||
// nothing is mapped. A glyph the subset dropped is left unmapped: no code can draw it, and
|
||||
// its advance is the subsetter's filler, not the glyph's.
|
||||
let program = embedded_truetype(doc, &desc)?;
|
||||
let face = skrifa::FontRef::new(&program).ok()?;
|
||||
let loca = face.loca(None).ok()?;
|
||||
let advances = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
|
||||
let mut out = Vec::new();
|
||||
for (gid, ch) in nominal.chain(contextual) {
|
||||
let at = usize::from(gid);
|
||||
if loca.get_raw(at).zip(loca.get_raw(at + 1)).is_none_or(|(start, end)| end <= start) {
|
||||
continue;
|
||||
}
|
||||
if advances.advance_width(skrifa::GlyphId::new(u32::from(gid))).is_none_or(|w| (w == 0.0) != is_thai_mark(ch)) {
|
||||
return None;
|
||||
}
|
||||
out.push((gid, ch));
|
||||
}
|
||||
Some(out)
|
||||
}
|
||||
|
||||
/// The character each legacy Windows Thai presentation form U+F700 + index stands for: ฐ and ญ
|
||||
/// without descenders, and the vowels and tone marks moved left, lower or both (the PUA
|
||||
/// assignments of <https://linux.thai.net/~thep/th-otf/shaping.html>).
|
||||
const PUA_BASES: [char; 27] = ['ฐ', 'ิ', 'ี', 'ึ', 'ื', '่', '้', '๊', '๋', '์', '่', '้', '๊', '๋', '์', 'ญ', 'ั', 'ํ', '็', '่', '้', '๊', '๋', '์', 'ุ', 'ู', 'ฺ'];
|
||||
|
||||
/// Whether a Thai character is a vowel or tone mark set above or below its base, with no advance
|
||||
/// of its own (U+0E31, U+0E34–U+0E3A, U+0E47–U+0E4E). Sara Am and the other vowels advance.
|
||||
fn is_thai_mark(ch: char) -> bool {
|
||||
matches!(u32::from(ch), 0x0e31 | 0x0e34..=0x0e3a | 0x0e47..=0x0e4e)
|
||||
}
|
||||
|
||||
/// The glyph a legacy THSarabunPSK program draws Thai character `ch` with: U+0E01–U+0E3A are
|
||||
/// glyphs 214–271 and U+0E3F–U+0E5B glyphs 272–298 (the order `recovered_thai_map` assumes).
|
||||
fn legacy_glyph(ch: u32) -> Option<u32> {
|
||||
match ch {
|
||||
0x0e01..=0x0e3a => ch.checked_sub(0xd2b),
|
||||
0x0e3f..=0x0e5b => ch.checked_sub(0xd2f),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The TrueType program (`/FontFile2`) of a CIDFont, decoded within the editor's size cap.
|
||||
fn embedded_truetype(doc: &Document, descendant: &Dict) -> Option<Vec<u8>> {
|
||||
let descriptor = dict(doc, descendant.get(b"FontDescriptor"))?;
|
||||
let program = doc.resolve(descriptor.get(b"FontFile2")?);
|
||||
let Object::Stream(program) = program.as_ref() else { return None };
|
||||
DecodeBudget::new(super::MAX_SFNT, 0).decode_within(&doc.with_direct_parms(program), super::MAX_SFNT).ok()
|
||||
}
|
||||
|
||||
/// Source glyph positions and the original PDF character codes that draw them. Empty codes
|
||||
/// represent whitespace advances: no glyph is added to the font or substituted for a space.
|
||||
pub struct ExistingThaiRun {
|
||||
pub shaped: crate::source_shaping::ShapedRun,
|
||||
pub codes: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Unicode-to-glyph and glyph-to-PDF-code maps for a two-byte Identity-H TrueType font.
|
||||
/// Used by the editor-only cmap adapter and by direct source-font shaping.
|
||||
pub(super) fn composite_glyph_maps(
|
||||
doc: &Document,
|
||||
font: &Dict,
|
||||
bytes: &[u8],
|
||||
metrics: &Metrics,
|
||||
budget: &mut DecodeBudget,
|
||||
) -> Option<(BTreeMap<u32, u32>, BTreeMap<u32, u16>)> {
|
||||
use skrifa::{MetadataProvider as _, raw::TableProvider as _};
|
||||
if font.name(b"Encoding") != Some(b"Identity-H") {
|
||||
return None;
|
||||
}
|
||||
let descendant = dict(doc, font.get(b"DescendantFonts").and_then(|a| doc.resolve(a).as_array().and_then(|a| a.first()).cloned()).as_ref())?;
|
||||
let cid_map = match descendant.get(b"CIDToGIDMap").map(|m| doc.resolve(m)).as_deref() {
|
||||
None => None,
|
||||
Some(Object::Name(n)) if n == b"Identity" => None,
|
||||
Some(Object::Stream(s)) => Some(budget.decode_within(&doc.with_direct_parms(s), 1 << 17).ok()?),
|
||||
_ => return None,
|
||||
};
|
||||
let face = skrifa::FontRef::new(bytes).ok()?;
|
||||
let loca = face.loca(None).ok()?;
|
||||
let advances = face.glyph_metrics(skrifa::instance::Size::unscaled(), skrifa::instance::LocationRef::default());
|
||||
let outlined = |gid: u32| loca.get_raw(gid as usize).zip(loca.get_raw(gid as usize + 1)).is_some_and(|(a, b)| b > a);
|
||||
let glyph_of = |cid: u16| -> Option<u32> {
|
||||
match &cid_map {
|
||||
None => Some(u32::from(cid)),
|
||||
Some(map) => {
|
||||
let at = usize::from(cid).checked_mul(2)?;
|
||||
Some(u32::from(u16::from_be_bytes(map.get(at..at + 2)?.try_into().ok()?)))
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut unicode = BTreeMap::new();
|
||||
let mut codes = BTreeMap::new();
|
||||
for code in 0..=u16::MAX {
|
||||
let Some(gid) = glyph_of(code) else { continue };
|
||||
if gid == 0 || gid as usize >= loca.len().saturating_sub(1) {
|
||||
continue;
|
||||
}
|
||||
let text = metrics.text_of(u32::from(code));
|
||||
let ch = text.as_deref().and_then(only_char);
|
||||
let space = ch == Some(' ') && advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| w > 0.0);
|
||||
if !outlined(gid) && !space {
|
||||
continue;
|
||||
}
|
||||
codes.entry(gid).or_insert(code);
|
||||
if let Some(ch) = ch.filter(|c| !c.is_control()) {
|
||||
unicode.entry(u32::from(ch)).and_modify(|old: &mut u32| *old = (*old).min(gid)).or_insert(gid);
|
||||
}
|
||||
}
|
||||
// The verified THSarabunPSK glyph order is retained by these PDF subsets. Restore the
|
||||
// Windows Thai PUA alternates for HarfRust's legacy shaper, only where outlines survive.
|
||||
// See https://linux.thai.net/~thep/th-otf/shaping.html (legacy PUA assignments).
|
||||
let family = metrics.base_font.rsplit('+').next().unwrap_or(&metrics.base_font);
|
||||
let alternate_meanings = metrics.text_of(352).is_some_and(|t| t == "่") && metrics.text_of(353).is_some_and(|t| t == "้");
|
||||
// Each alternate the subset kept must advance as its form does: U+F700 and U+F70F are ฐ and
|
||||
// ญ without their descenders, the rest marks with no advance of their own. Any that doesn't
|
||||
// means another glyph order, and then none is used: the wrong mark would draw silently.
|
||||
let kept: Vec<(usize, u32)> = (342u32..=368).enumerate().filter(|&(_, gid)| outlined(gid)).collect();
|
||||
let in_order =
|
||||
kept.iter().all(|&(index, gid)| advances.advance_width(skrifa::GlyphId::new(gid)).is_some_and(|w| (w > 0.0) == matches!(index, 0 | 15)));
|
||||
// Where the PDF says what an alternate's code is (an intact ToUnicode gives the character it
|
||||
// stands for), it must be that alternate's base character: two alternates of the same kind
|
||||
// in another order pass the advance check, but not this one.
|
||||
let code_of = |gid: u32| match &cid_map {
|
||||
None => u16::try_from(gid).ok(),
|
||||
Some(_) => codes.get(&gid).copied(),
|
||||
};
|
||||
let meanings_agree = kept.iter().all(|&(index, gid)| {
|
||||
let stated = code_of(gid).and_then(|code| metrics.text_of(u32::from(code))).and_then(|t| only_char(&t));
|
||||
stated.filter(|c| matches!(u32::from(*c), 0x0e01..=0x0e5b)).is_none_or(|c| PUA_BASES.get(index) == Some(&c))
|
||||
});
|
||||
if family.starts_with("THSarabunPSK")
|
||||
&& unicode.get(&0x0e01) == Some(&214)
|
||||
&& unicode.get(&0x0e2d) == Some(&258)
|
||||
&& (alternate_meanings || unicode.contains_key(&0xf70a))
|
||||
&& in_order
|
||||
&& meanings_agree
|
||||
{
|
||||
for (index, gid) in kept {
|
||||
let code = match &cid_map {
|
||||
None => u16::try_from(gid).ok(),
|
||||
Some(_) => codes.get(&gid).copied(),
|
||||
};
|
||||
if let Some(code) = code {
|
||||
unicode.insert(0xf700 + index as u32, gid);
|
||||
codes.entry(gid).or_insert(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some((unicode, codes))
|
||||
}
|
||||
|
||||
/// Shape Thai in a THSarabunPSK source font's own outlines and return codes for that same font.
|
||||
/// No font program, family, weight or size is replaced. A program with OpenType Thai mark
|
||||
/// positioning (a complete face loaded with Load font, say) is shaped by that layout; one
|
||||
/// without it must be a verified legacy positional program, shaped with its nominal glyphs and
|
||||
/// Windows PUA alternates. A subset missing a glyph the text needs is refused, so the caller can
|
||||
/// try another subset of the same face or ask for its full font.
|
||||
pub struct LegacyThaiFont {
|
||||
bytes: Vec<u8>,
|
||||
codes: BTreeMap<u32, u16>,
|
||||
space: f64,
|
||||
/// The program positions Thai marks itself (GPOS `mark`/`mkmk`).
|
||||
positioned: bool,
|
||||
}
|
||||
|
||||
impl LegacyThaiFont {
|
||||
/// Read and verify the source font once for all wrapping/preview measurements. `None` for
|
||||
/// another family, a font with no Thai, and a program without Thai mark positioning whose
|
||||
/// Thai glyphs are not in the legacy order: the nominal and alternate marks this shaper
|
||||
/// assumes can't be trusted there, so it is shaped as an ordinary source font.
|
||||
///
|
||||
/// Its streams are decoded against `budget`, which the caller shares between every font it
|
||||
/// reads: what they keep is the programs decoded and the editor cmaps added to them.
|
||||
pub fn from_pdf(doc: &Document, font: &Dict, space: f64, budget: &mut DecodeBudget) -> Result<Option<Self>, String> {
|
||||
let m = Metrics::from_dict(doc, font);
|
||||
if !m.base_font.rsplit('+').next().unwrap_or(&m.base_font).starts_with("THSarabunPSK") {
|
||||
return Ok(None);
|
||||
}
|
||||
if !space.is_finite() || space < 0.0 {
|
||||
return Err("the source space width is invalid".into());
|
||||
}
|
||||
let mut bytes = super::embedded_sfnt_within(doc, font, budget).ok_or("the source Thai font has no readable glyph program")?;
|
||||
let positioned = crate::source_shaping::has_thai_layout(&bytes);
|
||||
let (unicode, codes) = composite_glyph_maps(doc, font, &bytes, &m, budget).ok_or("the Thai source glyph codes cannot be recovered safely")?;
|
||||
// A subset keeps only the glyphs its document used, so no particular letter or alternate
|
||||
// is required here: [`Self::shape`] refuses a text whose glyphs, or required alternates
|
||||
// ([`require_pua`]), the subset lacks. Without its own positioning, every Thai glyph it
|
||||
// does keep must be where the legacy order puts that character, or one of the PUA
|
||||
// alternates for a mark (or for ฐ and ญ without their descenders).
|
||||
let mut thai = unicode.range(0x0e01..=0x0e5b).peekable();
|
||||
if thai.peek().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
let usable = positioned
|
||||
|| thai.all(|(&ch, &gid)| {
|
||||
legacy_glyph(ch) == Some(gid)
|
||||
|| ((342..=368).contains(&gid) && (char::from_u32(ch).is_some_and(is_thai_mark) || matches!(ch, 0x0e0d | 0x0e10)))
|
||||
});
|
||||
if !usable {
|
||||
return Ok(None);
|
||||
}
|
||||
if !positioned {
|
||||
// Glyphs 342–368 are alternates moved for a context. Mapped as the nominal character
|
||||
// (its glyph dropped), one would be drawn out of place where the nominal form is
|
||||
// needed: the shaper reaches it only through its PUA form, and that text is refused.
|
||||
use skrifa::MetadataProvider as _;
|
||||
let alternate = |u: u32, g: skrifa::GlyphId| matches!(u, 0x0e01..=0x0e5b) && (342..=368).contains(&g.to_u32());
|
||||
let charmap = skrifa::FontRef::new(&bytes).map_err(|e| e.to_string())?.charmap();
|
||||
if charmap.mappings().any(|(u, g)| alternate(u, g)) {
|
||||
let nominal: BTreeMap<u32, u32> =
|
||||
charmap.mappings().filter(|&(u, g)| g.to_u32() != 0 && !alternate(u, g)).take(65_537).map(|(u, g)| (u, g.to_u32())).collect();
|
||||
super::append_editor_cmap(&mut bytes, &nominal).ok_or("the Thai source glyph map cannot be rebuilt safely")?;
|
||||
}
|
||||
}
|
||||
Ok(Some(Self { bytes, codes, space, positioned }))
|
||||
}
|
||||
|
||||
/// Shape into glyphs that are already in this exact source face.
|
||||
pub fn shape(&self, text: &str) -> Result<ExistingThaiRun, String> {
|
||||
use skrifa::MetadataProvider as _;
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err("shape one bounded Thai source row at a time".into());
|
||||
}
|
||||
// Only the legacy shaper needs the PUA alternates; a positioned program moves its marks.
|
||||
if !self.positioned {
|
||||
let face = skrifa::FontRef::new(&self.bytes).map_err(|e| e.to_string())?;
|
||||
require_pua(&face.charmap(), text)?;
|
||||
}
|
||||
let mut shaped = crate::source_shaping::ShapedRun { glyphs: Vec::new(), advance: 0.0 };
|
||||
let mut codes = Vec::new();
|
||||
// Legacy PDF subsets often omit the space glyph. Keep its measured PDF advance as a
|
||||
// positioning gap, rather than drawing .notdef or changing to another font.
|
||||
for piece in text.split_inclusive(' ') {
|
||||
let word = piece.strip_suffix(' ').unwrap_or(piece);
|
||||
if !word.is_empty() {
|
||||
let run = if self.positioned {
|
||||
crate::source_shaping::shape(&self.bytes, word)?
|
||||
} else {
|
||||
crate::source_shaping::shape_legacy_thai(&self.bytes, word)?
|
||||
};
|
||||
for mut glyph in run.glyphs {
|
||||
let code = self
|
||||
.codes
|
||||
.get(&glyph.id.to_u32())
|
||||
.ok_or("the Thai subset lacks a required contextual glyph; load the complete original face")?;
|
||||
glyph.position[0] += shaped.advance;
|
||||
codes.push(code.to_be_bytes().to_vec());
|
||||
shaped.glyphs.push(glyph);
|
||||
}
|
||||
shaped.advance += run.advance;
|
||||
}
|
||||
if piece.ends_with(' ') {
|
||||
shaped.glyphs.push(crate::source_shaping::ShapedGlyph {
|
||||
id: skrifa::GlyphId::NOTDEF,
|
||||
position: [shaped.advance, 0.0],
|
||||
text: " ".into(),
|
||||
});
|
||||
codes.push(Vec::new());
|
||||
shaped.advance += self.space;
|
||||
}
|
||||
if shaped.glyphs.len() > 65_534 || !shaped.advance.is_finite() {
|
||||
return Err("Thai source row exceeds the shaping budget".into());
|
||||
}
|
||||
}
|
||||
Ok(ExistingThaiRun { shaped, codes })
|
||||
}
|
||||
}
|
||||
|
||||
/// Verify every context-dependent PUA alternate before asking the legacy shaper to use it.
|
||||
/// Otherwise a subset missing an alternate would silently draw the wrong high/left mark.
|
||||
fn require_pua(cmap: &skrifa::charmap::Charmap<'_>, text: &str) -> Result<(), String> {
|
||||
let mut base = 0u32;
|
||||
let mut above = false;
|
||||
let mut chars = text.chars().peekable();
|
||||
while let Some(ch) = chars.next() {
|
||||
let c = u32::from(ch);
|
||||
let ascender = matches!(base, 0xe1b | 0xe1d | 0xe1f);
|
||||
let pua = match c {
|
||||
0xe31 if ascender => Some(0xf710),
|
||||
0xe34..=0xe37 if ascender => Some(0xf701 + c - 0xe34),
|
||||
0xe47 if ascender => Some(0xf712),
|
||||
0xe4d if ascender => Some(0xf711),
|
||||
0xe48..=0xe4c => {
|
||||
let high = above || chars.peek().is_some_and(|c| *c == 'ำ');
|
||||
if ascender {
|
||||
Some((if high { 0xf713 } else { 0xf705 }) + c - 0xe48)
|
||||
} else if !high {
|
||||
Some(0xf70a + c - 0xe48)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
0xe38..=0xe3a if matches!(base, 0xe0e | 0xe0f) => Some(0xf718 + c - 0xe38),
|
||||
0xe38..=0xe3a if base == 0xe0d => Some(0xf70f),
|
||||
0xe38..=0xe3a if base == 0xe10 => Some(0xf700),
|
||||
0xe33 if ascender => Some(0xf711),
|
||||
_ => None,
|
||||
};
|
||||
if pua.is_some_and(|p| char::from_u32(p).is_none_or(|c| cmap.map(c).is_none())) {
|
||||
return Err(format!(
|
||||
"the Thai subset lacks a required contextual mark for {ch} (U+{c:04X}); load the complete original face or explicitly select a replacement font"
|
||||
));
|
||||
}
|
||||
if matches!(c, 0xe01..=0xe2e) {
|
||||
base = c;
|
||||
above = false;
|
||||
} else if matches!(c, 0xe31 | 0xe34..=0xe37 | 0xe47 | 0xe4d..=0xe4e) {
|
||||
above = true;
|
||||
} else if !matches!(c, 0xe38..=0xe3a | 0xe48..=0xe4c) {
|
||||
base = 0;
|
||||
above = false;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{LegacyThaiFont, Metrics, composite_glyph_maps, is_thai_mark, recovered_thai_map};
|
||||
use pdfcraft_cos::{DecodeBudget, Dict, Document, Object, Stream};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Where table `tag` starts in an sfnt.
|
||||
fn table(program: &[u8], tag: &[u8; 4]) -> usize {
|
||||
let count = u16::from_be_bytes([program[4], program[5]]) as usize;
|
||||
let record = (0..count).map(|i| 12 + i * 16).find(|&r| &program[r..r + 4] == tag).unwrap();
|
||||
u32::from_be_bytes(program[record + 8..record + 12].try_into().unwrap()) as usize
|
||||
}
|
||||
|
||||
/// Set glyph `gid`'s advance (a long `hmtx` entry).
|
||||
fn set_advance(program: &mut [u8], gid: usize, advance: u16) {
|
||||
let long = u16::from_be_bytes(program[table(program, b"hhea") + 34..][..2].try_into().unwrap()) as usize;
|
||||
assert!(gid < long, "a long hmtx entry");
|
||||
let at = table(program, b"hmtx") + gid * 4;
|
||||
program[at..at + 2].copy_from_slice(&advance.to_be_bytes());
|
||||
}
|
||||
|
||||
/// Empty glyph `gid`, as a subsetter drops it: its data is left as the previous glyph's padding.
|
||||
fn drop_glyph(program: &mut [u8], gid: usize) {
|
||||
let long = program[table(program, b"head") + 51] == 1;
|
||||
let loca = table(program, b"loca");
|
||||
if long {
|
||||
let next: [u8; 4] = program[loca + (gid + 1) * 4..][..4].try_into().unwrap();
|
||||
program[loca + gid * 4..][..4].copy_from_slice(&next);
|
||||
} else {
|
||||
let next: [u8; 2] = program[loca + (gid + 1) * 2..][..2].try_into().unwrap();
|
||||
program[loca + gid * 2..][..2].copy_from_slice(&next);
|
||||
}
|
||||
}
|
||||
|
||||
/// Inter's outlines standing in for a THSarabunPSK subset (no proprietary font is used): the
|
||||
/// glyphs where the exporter's order has a Thai mark or a mark-like alternate take no
|
||||
/// advance, the others 500 units.
|
||||
fn thai_ordered_program() -> Vec<u8> {
|
||||
let mut program = crate::script::INTER_REGULAR.to_vec();
|
||||
for gid in 214..=368usize {
|
||||
let mark = match gid {
|
||||
214..=298 => is_thai_mark(char::from_u32(gid as u32 + if gid <= 271 { 0xd2b } else { 0xd2f }).unwrap()),
|
||||
342..=368 => !matches!(gid, 342 | 357),
|
||||
_ => continue,
|
||||
};
|
||||
set_advance(&mut program, gid, if mark { 0 } else { 500 });
|
||||
}
|
||||
program
|
||||
}
|
||||
|
||||
/// A THSarabunPSK Identity-H font over `program`, with `to_unicode`.
|
||||
fn thai_font(doc: &mut Document, program: &[u8], to_unicode: &[u8]) -> Dict {
|
||||
let file = Object::Ref(doc.add(Object::Stream(Stream::flate(Dict::new(), program))));
|
||||
thai_font_over(doc, file, Object::name("Identity"), to_unicode)
|
||||
}
|
||||
|
||||
/// [`thai_font`] over the `/FontFile2` stream `file` and the `/CIDToGIDMap` `cid_to_gid`.
|
||||
fn thai_font_over(doc: &mut Document, file: Object, cid_to_gid: Object, to_unicode: &[u8]) -> Dict {
|
||||
let tu = doc.add(Object::Stream(Stream::flate(Dict::new(), to_unicode)));
|
||||
let mut descriptor = Dict::new();
|
||||
descriptor.set(b"FontFile2".to_vec(), file);
|
||||
let mut desc = Dict::new();
|
||||
desc.set(b"CIDToGIDMap".to_vec(), cid_to_gid);
|
||||
desc.set(b"FontDescriptor".to_vec(), Object::Dict(descriptor));
|
||||
let mut font = Dict::new();
|
||||
font.set(b"Subtype".to_vec(), Object::name("Type0"));
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK"));
|
||||
font.set(b"Encoding".to_vec(), Object::name("Identity-H"));
|
||||
font.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
font.set(b"ToUnicode".to_vec(), Object::Ref(tu));
|
||||
font
|
||||
}
|
||||
|
||||
/// `data` as a Flate stream under a PNG predictor whose `/DecodeParms` is given by reference:
|
||||
/// read without that dictionary, each row's tag byte stays in the decoded bytes.
|
||||
fn predicted(doc: &mut Document, data: &[u8]) -> Object {
|
||||
let rows: Vec<u8> = data.iter().flat_map(|byte| [0, *byte]).collect();
|
||||
let mut parms = Dict::new();
|
||||
parms.set(b"Predictor".to_vec(), Object::Int(12));
|
||||
parms.set(b"Columns".to_vec(), Object::Int(1));
|
||||
let parms = doc.add(Object::Dict(parms));
|
||||
let mut stream = Stream::flate(Dict::new(), &rows);
|
||||
stream.dict.set(b"DecodeParms".to_vec(), Object::Ref(parms));
|
||||
Object::Ref(doc.add(Object::Stream(stream)))
|
||||
}
|
||||
|
||||
/// An intact legacy map: nominal Thai and two tone alternates.
|
||||
const INTACT: &[u8] = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
|
||||
/// The exporter's damaged map: intact anchors, Latin/control destinations for the other Thai
|
||||
/// codes, and the oversized range first.
|
||||
const DAMAGED: &[u8] = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange \
|
||||
1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange \
|
||||
<00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \
|
||||
<0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \
|
||||
<00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \
|
||||
<0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> endbfrange endcmap";
|
||||
|
||||
/// The anchors identify the exporter, but the glyph order is still checked glyph by glyph: a
|
||||
/// mark where a letter belongs (or the reverse) anywhere in the kept glyphs refuses the whole
|
||||
/// map, and a glyph the subset dropped is left unmapped whatever its filler advance says.
|
||||
#[test]
|
||||
fn recovered_thai_map_checks_every_kept_glyph_against_the_font_program() {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &thai_ordered_program(), DAMAGED);
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
assert_eq!(recovered[&262], 'ั');
|
||||
assert_eq!(recovered[&297], '๘');
|
||||
// 259–261 are ฮ, ฯ and ะ, between the last anchor (อ, 258) and the first mark: a font that
|
||||
// put Mai Han-akat one glyph earlier passes every anchor and only this check sees it.
|
||||
let mut shifted = thai_ordered_program();
|
||||
set_advance(&mut shifted, 261, 0);
|
||||
set_advance(&mut shifted, 262, 500);
|
||||
let shifted = thai_font(&mut doc, &shifted, DAMAGED);
|
||||
assert!(recovered_thai_map(&doc, &shifted).is_none(), "a kept glyph that advances unlike its character refuses the map");
|
||||
let mut dropped = thai_ordered_program();
|
||||
drop_glyph(&mut dropped, 262);
|
||||
set_advance(&mut dropped, 262, 500);
|
||||
let dropped = thai_font(&mut doc, &dropped, DAMAGED);
|
||||
let recovered = recovered_thai_map(&doc, &dropped).unwrap();
|
||||
assert!(recovered.iter().all(|(gid, _)| *gid != 262), "a dropped glyph is not mapped");
|
||||
assert!(recovered.iter().any(|(gid, _)| *gid == 263));
|
||||
let mut unreadable = font.clone();
|
||||
let Some(Object::Array(desc)) = unreadable.get(b"DescendantFonts").cloned() else { panic!("fixture") };
|
||||
let Some(Object::Dict(mut desc)) = desc.first().cloned() else { panic!("fixture") };
|
||||
desc.remove(b"FontDescriptor");
|
||||
unreadable.set(b"DescendantFonts".to_vec(), Object::Array(vec![Object::Dict(desc)]));
|
||||
assert!(recovered_thai_map(&doc, &unreadable).is_none(), "without the program nothing is verified, so nothing is mapped");
|
||||
}
|
||||
|
||||
/// `program` without the tables a legacy subsetter leaves out.
|
||||
fn without_tables(program: &[u8], tags: &[&[u8; 4]]) -> Vec<u8> {
|
||||
let mut program = program.to_vec();
|
||||
let count = u16::from_be_bytes([program[4], program[5]]) as usize;
|
||||
let records: Vec<Vec<u8>> =
|
||||
program[12..12 + count * 16].chunks(16).filter(|r| !tags.iter().any(|t| &r[..4] == t.as_slice())).map(<[u8]>::to_vec).collect();
|
||||
program[4..6].copy_from_slice(&(records.len() as u16).to_be_bytes());
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
program[12 + i * 16..28 + i * 16].copy_from_slice(record);
|
||||
}
|
||||
program
|
||||
}
|
||||
|
||||
/// The font program and `/CIDToGIDMap` are read with `/DecodeParms` given by reference, as a
|
||||
/// viewer reads them: the map repair, the glyph codes and the shaper see the real bytes.
|
||||
#[test]
|
||||
fn thai_font_streams_are_read_with_decode_parms_given_by_reference() {
|
||||
let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
let mut doc = Document::new_empty();
|
||||
let program = predicted(&mut doc, &stripped);
|
||||
let damaged = thai_font_over(&mut doc, program.clone(), Object::name("Identity"), DAMAGED);
|
||||
assert!(recovered_thai_map(&doc, &damaged).is_some(), "the program is read with its predictor");
|
||||
let gids: Vec<u8> = (0u16..=368).flat_map(u16::to_be_bytes).collect();
|
||||
let map = predicted(&mut doc, &gids);
|
||||
let font = thai_font_over(&mut doc, program, map, INTACT);
|
||||
let metrics = Metrics::from_dict(&doc, &font);
|
||||
let (map, _) = composite_glyph_maps(&doc, &font, &stripped, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap();
|
||||
assert_eq!(map.get(&0x0e01), Some(&214), "the CIDToGIDMap is read with its predictor");
|
||||
let shaped =
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap();
|
||||
assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [214, 352]);
|
||||
}
|
||||
|
||||
/// A legacy subset that kept a Latin-only cmap of its own still reaches its Thai glyphs through
|
||||
/// the PDF's verified maps, and keeps its own mappings.
|
||||
#[test]
|
||||
fn a_latin_only_cmap_does_not_hide_a_legacy_subsets_thai_glyphs() {
|
||||
use skrifa::MetadataProvider as _;
|
||||
let program = without_tables(&thai_ordered_program(), &[b"GSUB", b"GPOS"]);
|
||||
let latin = skrifa::FontRef::new(&program).unwrap().charmap().map('A').unwrap();
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &program, INTACT);
|
||||
let shaped =
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset").shape("ก่").unwrap();
|
||||
assert_eq!(shaped.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [214, 352]);
|
||||
let editor = crate::pdf::embedded_sfnt(&doc, &font).unwrap();
|
||||
assert_eq!(skrifa::FontRef::new(&editor).unwrap().charmap().map('A'), Some(latin), "the program's own mappings stay");
|
||||
}
|
||||
|
||||
/// Only Thai with a mark to set on its base needs the program's mark positioning: digits and
|
||||
/// bare letters shape without it.
|
||||
#[test]
|
||||
fn thai_without_marks_shapes_without_mark_positioning() {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]), INTACT);
|
||||
let program = crate::pdf::embedded_sfnt(&doc, &font).unwrap();
|
||||
let shaped = crate::source_shaping::shape(&program, "๑๒๓กข").expect("nothing to position");
|
||||
assert_eq!(shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>(), [290, 291, 292, 214, 215]);
|
||||
let error = crate::source_shaping::shape(&program, "กิ่").unwrap_err();
|
||||
assert!(error.contains("mark positioning"), "{error}");
|
||||
}
|
||||
|
||||
/// A legacy subset whose map gives a shifted alternate as the nominal mark (its nominal glyph
|
||||
/// dropped) draws it only where that alternate belongs, and refuses text needing the nominal.
|
||||
#[test]
|
||||
fn a_legacy_alternate_is_not_drawn_as_the_nominal_mark() {
|
||||
let mut program = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
drop_glyph(&mut program, 265);
|
||||
let unicode = [INTACT, b" 1 beginbfchar <0157> <0e34> endbfchar"].concat();
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, &program, &unicode);
|
||||
let font = LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().expect("a legacy subset");
|
||||
let ids = |text: &str| font.shape(text).map(|run| run.shaped.glyphs.iter().map(|g| g.id.to_u32()).collect::<Vec<_>>());
|
||||
assert_eq!(ids("ปิ"), Ok(vec![240, 343]), "after an ascender, the alternate is the form needed");
|
||||
assert!(ids("กิ").is_err(), "elsewhere the nominal ิ is needed, which the subset dropped");
|
||||
}
|
||||
|
||||
/// A THSarabunPSK program with its own Thai mark positioning is shaped by that layout; one
|
||||
/// without it only if every Thai glyph it keeps is where the legacy order has that character
|
||||
/// (the legacy shaper skips the positioning check). A subset needs no particular glyph.
|
||||
#[test]
|
||||
fn thai_source_programs_are_shaped_by_their_own_layout_or_as_verified_legacy() {
|
||||
let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
// `Some(positioned)` when the program can be shaped in place.
|
||||
let mode = |program: &[u8], unicode: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, program, unicode);
|
||||
LegacyThaiFont::from_pdf(&doc, &font, 0.25, &mut DecodeBudget::new(1 << 26, 0)).unwrap().map(|f| f.positioned)
|
||||
};
|
||||
let stripped = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB", b"GPOS"]);
|
||||
assert_eq!(mode(&stripped, unicode), Some(false), "a legacy positional subset");
|
||||
let positioned = without_tables(&thai_ordered_program(), &[b"cmap", b"GSUB"]);
|
||||
assert_eq!(mode(&positioned, unicode), Some(true), "a face with Thai mark positioning uses it");
|
||||
// ก at another glyph than the legacy order's.
|
||||
let moved = b"2 beginbfrange <00d7> <010f> <0e02> <0110> <012a> <0e3f> endbfrange 1 beginbfchar <0024> <0e01> endbfchar";
|
||||
assert_eq!(mode(&stripped, moved), None, "out of the legacy order and without its own positioning");
|
||||
assert_eq!(mode(&positioned, moved), Some(true), "its own layout doesn't depend on the legacy order");
|
||||
// Some of the legacy glyphs, no ก or อ among them.
|
||||
assert_eq!(mode(&stripped, b"1 beginbfrange <00d7> <00e0> <0e02> endbfrange"), Some(false));
|
||||
assert_eq!(mode(&stripped, b"1 beginbfchar <0024> <0041> endbfchar"), None, "no Thai at all");
|
||||
}
|
||||
|
||||
/// The Windows PUA alternates are used only when each one the subset kept advances as its
|
||||
/// form does: one mark out of place withdraws them all, so the shaper refuses instead of
|
||||
/// drawing another mark.
|
||||
#[test]
|
||||
fn legacy_thai_alternates_need_their_glyph_order_confirmed() {
|
||||
let unicode = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 2 beginbfchar <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
let alternates_with = |program: &[u8], unicode: &[u8]| {
|
||||
let mut doc = Document::new_empty();
|
||||
let font = thai_font(&mut doc, program, unicode);
|
||||
let metrics = Metrics::from_dict(&doc, &font);
|
||||
let (map, _) = composite_glyph_maps(&doc, &font, program, &metrics, &mut DecodeBudget::new(1 << 20, 0)).unwrap();
|
||||
map.range(0xf700..=0xf71a).map(|(u, g)| (*u, *g)).collect::<Vec<_>>()
|
||||
};
|
||||
let alternates = |program: &[u8]| alternates_with(program, unicode);
|
||||
let ordered = alternates(&thai_ordered_program());
|
||||
assert!(ordered.contains(&(0xf70a, 352)) && ordered.contains(&(0xf710, 358)), "{ordered:?}");
|
||||
let mut misplaced = thai_ordered_program();
|
||||
set_advance(&mut misplaced, 352, 500);
|
||||
assert!(alternates(&misplaced).is_empty(), "one misplaced alternate withdraws them all");
|
||||
// A dropped alternate is simply absent; the others still come from the kept glyphs.
|
||||
let mut dropped = thai_ordered_program();
|
||||
drop_glyph(&mut dropped, 353);
|
||||
let kept = alternates(&dropped);
|
||||
assert!(kept.contains(&(0xf70a, 352)) && !kept.iter().any(|(_, g)| *g == 353), "{kept:?}");
|
||||
// Two marks of the same kind (both zero-advance) in another order: the PDF's own map says
|
||||
// glyph 343 is ี and 344 is ิ, the reverse of U+F701 and U+F702. None is used.
|
||||
let stated = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e35> <0158> <0e34> <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
assert!(alternates_with(&thai_ordered_program(), stated).is_empty(), "a stated meaning out of the PUA order withdraws them all");
|
||||
let agreeing = b"2 beginbfrange <00d6> <010f> <0e01> <0110> <012a> <0e3f> endbfrange 4 beginbfchar <0157> <0e34> <0158> <0e35> <0160> <0e48> <0161> <0e49> endbfchar";
|
||||
assert!(alternates_with(&thai_ordered_program(), agreeing).contains(&(0xf701, 343)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damaged_legacy_thai_map_recovers_consonants_vowels_and_contextual_tones_only_for_its_fingerprint() {
|
||||
let mut doc = Document::new_empty();
|
||||
let data = b"begincmap 1 begincodespacerange <0000> <ffff> endcodespacerange 18 beginbfrange \
|
||||
<00d9><00d9><0e04> <00e2><00e2><0e0d> <00fa><00fa><0e25> <0100><0100><0e2b> <0102><0102><0e2d> \
|
||||
<0164><0164><0e4c> <0166><0166><0e31> <0168><0168><0e47> \
|
||||
<00d6><00d6><000f> <00f0><00f0><0029> <00f8><00f8><0023> <0105><0105><003e> <0107><0107><0040> \
|
||||
<0109><0109><003e> <0111><0111><004e> <0160><0160><004e> <0161><0161><004f> <00ff><01ff><0032> endbfrange endcmap";
|
||||
let mut font = thai_font(&mut doc, &thai_ordered_program(), data);
|
||||
let tu = font.get(b"ToUnicode").and_then(Object::as_ref).unwrap();
|
||||
// The corrupt range is declared before explicit anchors in the actual exporter.
|
||||
let data = String::from_utf8_lossy(data)
|
||||
.replace("18 beginbfrange", "1 beginbfrange <00ff><01ff><0032> endbfrange 17 beginbfrange")
|
||||
.replace(" <00ff><01ff><0032> endbfrange endcmap", " endbfrange endcmap");
|
||||
doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), data.as_bytes())));
|
||||
let recovered: HashMap<_, _> = recovered_thai_map(&doc, &font).unwrap().into_iter().collect();
|
||||
let title: String = [240, 248, 261, 214, 263, 253].into_iter().map(|code| recovered[&code]).collect();
|
||||
assert_eq!(title, "ประกาศ");
|
||||
assert_eq!(recovered[&265], 'ิ');
|
||||
assert_eq!(recovered[&273], 'เ');
|
||||
assert_eq!(recovered[&352], '่');
|
||||
assert_eq!(recovered[&353], '้');
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+Unrelated"));
|
||||
assert!(recovered_thai_map(&doc, &font).is_none());
|
||||
font.set(b"BaseFont".to_vec(), Object::name("ABCDEF+THSarabunPSK"));
|
||||
doc.set(tu, Object::Stream(pdfcraft_cos::Stream::flate(Dict::new(), b"1 beginbfchar <00d9><0e04> endbfchar")));
|
||||
assert!(recovered_thai_map(&doc, &font).is_none(), "one plausible anchor cannot authorize guessing a map");
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,45 @@ pub fn required(text: &str) -> bool {
|
||||
.any(|c| matches!(u32::from(c), 0x0300..=0x036f | 0x0590..=0x0fff | 0x1780..=0x17ff | 0x200c..=0x200f | 0x202a..=0x202e | 0x2066..=0x2069))
|
||||
}
|
||||
|
||||
/// Whether a Thai source program retains its mark positioning.
|
||||
/// Character coverage alone does not show that stacked vowels and tones can be laid out.
|
||||
/// OpenType Thai requires `mark`/`mkmk` (GPOS); `ccmp` substitution (GSUB) is optional.
|
||||
pub fn has_thai_layout(bytes: &[u8]) -> bool {
|
||||
FontRef::new(bytes).is_ok_and(|f| positions_thai_marks(&f))
|
||||
}
|
||||
|
||||
/// Whether the GPOS features the shaper uses for Thai include `mark` or `mkmk`. The selection is
|
||||
/// HarfRust's, through the same read-fonts calls: the `thai` script, else `DFLT`, `dflt` or
|
||||
/// `latn` (`ScriptList::select`); then, given no language, a language system tagged `dflt`,
|
||||
/// else the script's default one. Marks under a script or language it doesn't select don't
|
||||
/// count, nor does a GPOS with only kerning.
|
||||
fn positions_thai_marks(face: &FontRef) -> bool {
|
||||
use skrifa::Tag;
|
||||
let Ok(gpos) = face.gpos() else { return false };
|
||||
let (Ok(scripts), Ok(features)) = (gpos.script_list(), gpos.feature_list()) else { return false };
|
||||
let records = features.feature_records();
|
||||
let marks = |index: u16| records.get(usize::from(index)).is_some_and(|r| r.feature_tag() == *b"mark" || r.feature_tag() == *b"mkmk");
|
||||
let Some(selected) = scripts.select(&[Tag::new(b"thai")]) else { return false };
|
||||
let Ok(script) = scripts.get(selected.index) else { return false };
|
||||
let language = match script.lang_sys_index_for_tag(Tag::new(b"dflt")) {
|
||||
Some(index) => script.lang_sys(index).ok().map(|l| l.element),
|
||||
None => script.default_lang_sys().transpose().ok().flatten(),
|
||||
};
|
||||
let Some(language) = language else { return false };
|
||||
language.feature_indices().iter().any(|i| marks(i.get()))
|
||||
|| (language.required_feature_index() != 0xFFFF && marks(language.required_feature_index()))
|
||||
}
|
||||
|
||||
pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
shape_inner(bytes, text, false)
|
||||
}
|
||||
|
||||
/// Only used after the PDF adapter has verified legacy Thai PUA glyph mappings.
|
||||
pub(crate) fn shape_legacy_thai(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
shape_inner(bytes, text, true)
|
||||
}
|
||||
|
||||
fn shape_inner(bytes: &[u8], text: &str, legacy_thai: bool) -> Result<ShapedRun, String> {
|
||||
if text.len() > 262_144 || text.contains(['\n', '\r']) {
|
||||
return Err("shape one bounded text row at a time".into());
|
||||
}
|
||||
@@ -30,7 +68,14 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
return Ok(ShapedRun { glyphs: Vec::new(), advance: 0.0 });
|
||||
}
|
||||
let face = FontRef::new(bytes).map_err(|e| e.to_string())?;
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0fff | 0x1780..=0x17ff)) && face.gsub().is_err() {
|
||||
// Only a vowel or tone mark set on its base (or Sara Am, whose Nikhahit is) needs positioning:
|
||||
// digits and bare letters shape the same without it.
|
||||
if !legacy_thai && text.chars().any(|c| matches!(u32::from(c), 0x0e31 | 0x0e33..=0x0e3a | 0x0e47..=0x0e4e)) && !positions_thai_marks(&face) {
|
||||
return Err("the source subset has no Thai mark positioning (GPOS mark/mkmk); select its full font".into());
|
||||
}
|
||||
if text.chars().any(|c| matches!(u32::from(c), 0x0600..=0x06ff | 0x0750..=0x077f | 0x08a0..=0x0dff | 0x0e80..=0x0fff | 0x1780..=0x17ff))
|
||||
&& face.gsub().is_err()
|
||||
{
|
||||
return Err("the source subset has no contextual substitution tables; select its full font".into());
|
||||
}
|
||||
let data = ShaperData::new(&face);
|
||||
@@ -56,7 +101,11 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for (g, p) in output.glyph_infos().iter().zip(output.glyph_positions()) {
|
||||
if g.glyph_id == 0 {
|
||||
return Err("the full font lacks a required contextual glyph; choose the complete source face".into());
|
||||
let missing = input.get(g.cluster as usize..).and_then(|s| s.chars().next()).unwrap_or('\u{fffd}');
|
||||
return Err(format!(
|
||||
"the full font lacks a required contextual glyph for {missing} (U+{:04X}); choose the complete source face",
|
||||
u32::from(missing)
|
||||
));
|
||||
}
|
||||
let start = g.cluster as usize;
|
||||
let end = starts.get(starts.partition_point(|i| *i <= start)).copied().unwrap_or(input.len());
|
||||
@@ -74,3 +123,130 @@ pub fn shape(bytes: &[u8], text: &str) -> Result<ShapedRun, String> {
|
||||
}
|
||||
Ok(ShapedRun { glyphs, advance: x })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Where the GPOS table starts in an sfnt.
|
||||
fn gpos(bytes: &[u8]) -> usize {
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let record = (0..count).map(|i| 12 + i * 16).find(|&r| &bytes[r..r + 4] == b"GPOS").unwrap();
|
||||
u32::from_be_bytes(bytes[record + 8..record + 12].try_into().unwrap()) as usize
|
||||
}
|
||||
|
||||
fn u16_at(bytes: &[u8], at: usize) -> usize {
|
||||
u16::from_be_bytes([bytes[at], bytes[at + 1]]) as usize
|
||||
}
|
||||
|
||||
/// Where the GPOS ScriptList starts.
|
||||
fn script_list(bytes: &[u8]) -> usize {
|
||||
gpos(bytes) + u16_at(bytes, gpos(bytes) + 4)
|
||||
}
|
||||
|
||||
/// Where script `tag`'s table starts.
|
||||
fn script(bytes: &[u8], tag: &[u8; 4]) -> usize {
|
||||
let list = script_list(bytes);
|
||||
let record = (0..u16_at(bytes, list)).map(|i| list + 2 + i * 6).find(|&r| &bytes[r..r + 4] == tag).unwrap();
|
||||
list + u16_at(bytes, record + 4)
|
||||
}
|
||||
|
||||
/// Retag script `from` as `to`, keeping the records sorted by tag as the shaper's binary
|
||||
/// search needs.
|
||||
fn retag_script(bytes: &mut [u8], from: &[u8; 4], to: &[u8; 4]) {
|
||||
let list = script_list(bytes);
|
||||
let count = u16_at(bytes, list);
|
||||
let mut records: Vec<[u8; 6]> = (0..count).map(|i| bytes[list + 2 + i * 6..list + 8 + i * 6].try_into().unwrap()).collect();
|
||||
records.iter_mut().find(|r| &r[..4] == from).unwrap()[..4].copy_from_slice(to);
|
||||
records.sort_by(|a, b| a[..4].cmp(&b[..4]));
|
||||
for (i, record) in records.iter().enumerate() {
|
||||
bytes[list + 2 + i * 6..list + 8 + i * 6].copy_from_slice(record);
|
||||
}
|
||||
}
|
||||
|
||||
/// A shaper uses a font's `thai` script when it has one: marks the font registers only for
|
||||
/// Latin don't position Thai.
|
||||
#[test]
|
||||
fn thai_layout_counts_only_the_script_the_shaper_selects() {
|
||||
let mut bytes = crate::script::INTER_REGULAR.to_vec();
|
||||
// Inter's DFLT becomes a `thai` script whose default language system has no features;
|
||||
// its latn script keeps `mark` and `mkmk`.
|
||||
retag_script(&mut bytes, b"DFLT", b"thai");
|
||||
let thai = script(&bytes, b"thai");
|
||||
let language = thai + u16_at(&bytes, thai);
|
||||
assert_ne!(language, thai, "a default language system");
|
||||
bytes[language + 2..language + 6].copy_from_slice(&[0xFF, 0xFF, 0, 0]);
|
||||
assert!(!has_thai_layout(&bytes), "the latn script's marks are not the thai script's");
|
||||
}
|
||||
|
||||
/// Given no language, the shaper takes a language system tagged `dflt` before the script's
|
||||
/// default one, in both directions.
|
||||
#[test]
|
||||
fn thai_layout_follows_a_language_system_tagged_dflt() {
|
||||
// Without DFLT the shaper falls back to latn, whose default language system has marks and
|
||||
// is shared by its CAT, MOL and ROM records. Cyrillic's default one has only `kern`.
|
||||
let mut base = crate::script::INTER_REGULAR.to_vec();
|
||||
retag_script(&mut base, b"DFLT", b"zzzz");
|
||||
assert!(has_thai_layout(&base));
|
||||
let latn = script(&base, b"latn");
|
||||
let kern_only = script(&base, b"cyrl") + u16_at(&base, script(&base, b"cyrl"));
|
||||
let kern_only = u16::try_from(kern_only - latn).unwrap().to_be_bytes();
|
||||
let rom = (0..u16_at(&base, latn + 2)).map(|j| latn + 4 + j * 6).find(|&r| &base[r..r + 4] == b"ROM ").unwrap();
|
||||
// ROM becomes `dflt` (still sorted last) and has only kerning: it wins over the default.
|
||||
let mut named_without = base.clone();
|
||||
named_without[rom..rom + 4].copy_from_slice(b"dflt");
|
||||
named_without[rom + 4..rom + 6].copy_from_slice(&kern_only);
|
||||
assert!(!has_thai_layout(&named_without), "the dflt language system has no marks");
|
||||
// The default has only kerning and the `dflt` one keeps the marks: it wins again.
|
||||
let mut default_without = base.clone();
|
||||
default_without[rom..rom + 4].copy_from_slice(b"dflt");
|
||||
default_without[latn..latn + 2].copy_from_slice(&kern_only);
|
||||
assert!(has_thai_layout(&default_without), "the dflt language system has the marks");
|
||||
}
|
||||
|
||||
/// A GPOS table alone is not mark positioning: with its `mark` and `mkmk` features renamed,
|
||||
/// as in a kerning-only subset, a face can't stack Thai vowels and tones.
|
||||
#[test]
|
||||
fn thai_layout_needs_mark_positioning_features() {
|
||||
let inter = crate::script::INTER_REGULAR;
|
||||
assert!(has_thai_layout(inter), "Inter positions marks under its default script");
|
||||
let mut bytes = inter.to_vec();
|
||||
let table = gpos(&bytes);
|
||||
let features = table + u16_at(&bytes, table + 6);
|
||||
let mut renamed = 0;
|
||||
for i in 0..u16_at(&bytes, features) {
|
||||
let tag = features + 2 + i * 6;
|
||||
if matches!(&bytes[tag..tag + 4], b"mark" | b"mkmk") {
|
||||
bytes[tag..tag + 4].copy_from_slice(b"kern");
|
||||
renamed += 1;
|
||||
}
|
||||
}
|
||||
assert!(renamed > 0);
|
||||
assert!(FontRef::new(&bytes).unwrap().gpos().is_ok(), "the GPOS table still parses");
|
||||
assert!(!has_thai_layout(&bytes));
|
||||
let error = shape(&bytes, "กิ่").unwrap_err();
|
||||
assert!(error.contains("mark positioning"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thai_positioning_does_not_require_optional_gsub() {
|
||||
let Some(face) = crate::document_thai_font() else {
|
||||
eprintln!("skipping thai_positioning_does_not_require_optional_gsub: built without craft-fonts (set CRAFT_FONTS_DIR to run it)");
|
||||
return;
|
||||
};
|
||||
let mut bytes = face.bytes.to_vec();
|
||||
// Keep the actual GPOS anchors and outlines while removing optional substitutions.
|
||||
// Table payloads stay at their original offsets; no font fixture is committed.
|
||||
let count = u16::from_be_bytes([bytes[4], bytes[5]]) as usize;
|
||||
let tables: Vec<_> = bytes[12..12 + count * 16].as_chunks::<16>().0.iter().filter(|t| &t[..4] != b"GSUB").map(|t| t.to_vec()).collect();
|
||||
bytes[4..6].copy_from_slice(&(tables.len() as u16).to_be_bytes());
|
||||
for (i, table) in tables.iter().enumerate() {
|
||||
bytes[12 + i * 16..28 + i * 16].copy_from_slice(table);
|
||||
}
|
||||
assert!(FontRef::new(&bytes).unwrap().gsub().is_err());
|
||||
assert!(has_thai_layout(&bytes));
|
||||
let shaped = shape(&bytes, "กิ่ น้ำ").unwrap();
|
||||
assert!(shaped.advance > 0.0);
|
||||
assert!(shaped.glyphs.iter().all(|g| g.position.iter().all(|p| p.is_finite())));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod perspective;
|
||||
pub mod quad_points;
|
||||
|
||||
/// An axis-aligned rectangle in PDF user space (points).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
//! The quadrilaterals of a `/QuadPoints` array (ISO 32000-2 §12.5.6.10), read by one rule.
|
||||
//!
|
||||
//! Redaction apply, the comment summaries and outlines, the inspector and generated appearances
|
||||
//! all read `/QuadPoints`; they go through [`usable_quads`] so the area a user sees marked is the
|
||||
//! area apply removes.
|
||||
|
||||
/// The usable quadrilaterals of a `/QuadPoints` array, given its entries in order (`None` for an
|
||||
/// entry that is not a number).
|
||||
///
|
||||
/// A quadrilateral is usable only when all eight of its entries are present, numbers, and within
|
||||
/// the range of a PDF real (finite as an `f32`, Annex C), so the readers that keep `f32` see the
|
||||
/// same set as those that keep `f64`. Usable values come back on the `f32` grid (each one put
|
||||
/// through `f32` and back), so the readers fold the same numbers too: a value `f32` cannot hold
|
||||
/// exactly, like 67108869, is 67108872 for every reader, not just the ones that keep `f32`. An
|
||||
/// unusable one is skipped whole: the ones after it keep their coordinates, and it never becomes
|
||||
/// a folded box or zeros. A trailing partial group is dropped.
|
||||
pub fn usable_quads<I: IntoIterator<Item = Option<f64>>>(entries: I) -> Vec<[f64; 8]> {
|
||||
let entries: Vec<Option<f64>> = entries.into_iter().collect();
|
||||
entries
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|q| {
|
||||
let mut quad = [0.0; 8];
|
||||
for (slot, v) in quad.iter_mut().zip(q) {
|
||||
// NaN and infinities stay non-finite as `f32`; so does a value past its range.
|
||||
*slot = v.filter(|v| (*v as f32).is_finite()).map(|v| (v as f32) as f64)?;
|
||||
}
|
||||
Some(quad)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::usable_quads;
|
||||
|
||||
const A: [f64; 8] = [10.0, 20.0, 50.0, 20.0, 10.0, 40.0, 50.0, 40.0];
|
||||
const B: [f64; 8] = [60.0, 70.0, 90.0, 70.0, 60.0, 80.0, 90.0, 80.0];
|
||||
|
||||
fn some(v: &[f64]) -> Vec<Option<f64>> {
|
||||
v.iter().copied().map(Some).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finite_quads_are_all_usable() {
|
||||
assert_eq!(usable_quads(some(&[A, B].concat())), vec![A, B]);
|
||||
assert!(usable_quads(Vec::new()).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_nan_quad_is_unusable() {
|
||||
assert!(usable_quads(some(&[f64::NAN; 8])).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_non_finite_entry_makes_its_quad_unusable() {
|
||||
for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, 1e300] {
|
||||
let mut q = A;
|
||||
q[3] = bad;
|
||||
assert_eq!(usable_quads(some(&[q, B].concat())), vec![B], "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn usable_values_come_back_on_the_f32_grid() {
|
||||
let mut q = A;
|
||||
q[0] = 67_108_869.0; // `f32` cannot hold it exactly: it rounds to 67108872 there.
|
||||
q[1] = 0.1;
|
||||
let got = usable_quads(some(&q));
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0][0], (67_108_869.0f64 as f32) as f64);
|
||||
assert_eq!(got[0][1], (0.1f64 as f32) as f64);
|
||||
assert_ne!(got[0][0], q[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_non_number_drops_its_quad_without_shifting_the_next() {
|
||||
let mut entries = some(&[A, B].concat());
|
||||
entries[2] = None;
|
||||
assert_eq!(usable_quads(entries), vec![B]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trailing_partial_group_is_dropped() {
|
||||
assert_eq!(usable_quads(some(&[&A[..], &B[..4]].concat())), vec![A]);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,8 @@ workspace = true
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
ocrs = { version = "0.13.1", default-features = false, features = ["rten"] }
|
||||
rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] }
|
||||
serde_json.workspace = true
|
||||
sha2 = "0.11"
|
||||
thiserror.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
|
||||
+138
-1
@@ -29,7 +29,9 @@ pub mod tesseract;
|
||||
|
||||
pub use confidence::{ConfidenceBand, band_for, is_suspect, low_confidence};
|
||||
pub use merge::{MergeStrategy, StrategyOutcome, mean_confidence, recognize_with_strategy, rover_merge};
|
||||
pub use ocrs::{ALPHABET, DETECTION_MODEL, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL};
|
||||
pub use ocrs::{
|
||||
ALPHABET, DETECTION_MODEL, DETECTION_MODEL_SHA256, LANGUAGES, Models, Ocr, OcrsRecognizer, RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256,
|
||||
};
|
||||
|
||||
pub use pdfcraft_fonts::TextPlan;
|
||||
pub use pdfcraft_fonts::helvetica_width;
|
||||
@@ -464,6 +466,58 @@ pub fn text_layer(words: &[PlacedWord], plan: &pdfcraft_fonts::TextPlan) -> Vec<
|
||||
out
|
||||
}
|
||||
|
||||
/// The recognised lines as JSON, `[[["word", left, top, right, bottom], …], …]`, with the
|
||||
/// word's confidence as a sixth item when it has one: how the web build's OCR workers send what
|
||||
/// they found back to the page.
|
||||
pub fn lines_to_json(lines: &[Line]) -> String {
|
||||
let word = |w: &Word| {
|
||||
let mut items = vec![serde_json::json!(w.text), w.rect[0].into(), w.rect[1].into(), w.rect[2].into(), w.rect[3].into()];
|
||||
items.extend(w.confidence.map(serde_json::Value::from));
|
||||
serde_json::Value::Array(items)
|
||||
};
|
||||
serde_json::Value::Array(lines.iter().map(|l| serde_json::Value::Array(l.words.iter().map(word).collect())).collect()).to_string()
|
||||
}
|
||||
|
||||
/// At most this many words come back for one page (a dense page has a few thousand).
|
||||
const MAX_JSON_WORDS: usize = 100_000;
|
||||
|
||||
/// [`lines_to_json`] read back, the words credited to `source` (the engine the worker ran).
|
||||
/// Anything else (a word without its box, a box or confidence that isn't a finite number, too
|
||||
/// many words) is an error rather than a guess.
|
||||
pub fn lines_from_json(json: &str, source: &str) -> Result<Vec<Line>, OcrError> {
|
||||
let bad = |why: &str| OcrError::Recognize(format!("unreadable result: {why}"));
|
||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| bad(&e.to_string()))?;
|
||||
let lines = value.as_array().ok_or_else(|| bad("not a list of lines"))?;
|
||||
let number = |v: &serde_json::Value| v.as_f64().map(|v| v as f32).filter(|v| v.is_finite());
|
||||
let mut count = 0usize;
|
||||
let mut out = Vec::with_capacity(lines.len().min(MAX_JSON_WORDS));
|
||||
for line in lines {
|
||||
let mut words = Vec::new();
|
||||
for word in line.as_array().ok_or_else(|| bad("a line is not a list of words"))? {
|
||||
count += 1;
|
||||
if count > MAX_JSON_WORDS {
|
||||
return Err(bad("too many words"));
|
||||
}
|
||||
let (text, rect, confidence) = match word.as_array().map(Vec::as_slice) {
|
||||
Some([serde_json::Value::String(text), l, t, r, b]) => (text, [l, t, r, b], None),
|
||||
Some([serde_json::Value::String(text), l, t, r, b, c]) => (text, [l, t, r, b], Some(c)),
|
||||
_ => return Err(bad("a word is not [text, left, top, right, bottom, confidence?]")),
|
||||
};
|
||||
let mut r = [0f32; 4];
|
||||
for (slot, v) in r.iter_mut().zip(rect) {
|
||||
*slot = number(v).ok_or_else(|| bad("a word's box is not finite numbers"))?;
|
||||
}
|
||||
let mut w = Word::new(text.clone(), r, source);
|
||||
if let Some(c) = confidence {
|
||||
w = w.with_confidence(number(c).ok_or_else(|| bad("a word's confidence is not a finite number"))?);
|
||||
}
|
||||
words.push(w);
|
||||
}
|
||||
out.push(Line { words });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -854,6 +908,89 @@ mod tests {
|
||||
assert_eq!(ok.gray(-1, -1), Some(255));
|
||||
}
|
||||
|
||||
/// The web build loads the models from downloaded bytes; they read the same text.
|
||||
#[test]
|
||||
fn reads_rendered_text_with_models_from_bytes() {
|
||||
let Some(models) = Models::find().filter(|m| m.detection.ends_with(DETECTION_MODEL)) else {
|
||||
eprintln!("skipped: ONNX OCR models not installed");
|
||||
return;
|
||||
};
|
||||
let ocr = Ocr::from_bytes(std::fs::read(&models.detection).unwrap(), std::fs::read(&models.recognition).unwrap()).unwrap();
|
||||
let rec = OcrsRecognizer::from_ocr(ocr);
|
||||
assert!(rec.available(), "a recogniser loaded from bytes needs no model files");
|
||||
let (w, h, px) = test_image::hello();
|
||||
let lines = rec.recognize(&OcrImage::new(w, h, px).unwrap(), &RecognizeOptions::default()).unwrap();
|
||||
let text: Vec<String> = lines.iter().map(Line::text).collect();
|
||||
assert!(text.iter().any(|t| t.contains("HELL") && t.contains("WOR")), "{text:?}");
|
||||
}
|
||||
|
||||
/// The web build's workers send lines back as JSON; they arrive unchanged, credited to the
|
||||
/// engine the worker ran.
|
||||
#[test]
|
||||
fn lines_survive_json() {
|
||||
let lines = vec![
|
||||
Line {
|
||||
words: vec![
|
||||
Word::new("Total \"€250\"", [1.5, 2.0, 30.25, 12.0], "ocrs"),
|
||||
Word::new("due", [32.0, 2.0, 50.0, 12.0], "ocrs").with_confidence(87.5),
|
||||
],
|
||||
},
|
||||
Line::default(),
|
||||
Line { words: vec![Word::new("日本語", [0.0, 20.0, 9.0, 30.0], "ocrs")] },
|
||||
];
|
||||
assert_eq!(lines_from_json(&lines_to_json(&lines), "ocrs").unwrap(), lines);
|
||||
assert_eq!(lines_from_json(&lines_to_json(&[]), "ocrs").unwrap(), Vec::<Line>::new());
|
||||
let other = lines_from_json(&lines_to_json(&lines), "tesseract").unwrap();
|
||||
assert!(other.iter().flat_map(|l| &l.words).all(|w| w.source == "tesseract"));
|
||||
}
|
||||
|
||||
/// A result that isn't what a worker sends is an error, not a crash or a guess.
|
||||
#[test]
|
||||
fn malformed_json_lines_are_an_error() {
|
||||
for json in [
|
||||
"",
|
||||
"{}",
|
||||
"[1]",
|
||||
"[[1]]",
|
||||
"[[[\"a\", 1, 2, 3]]]",
|
||||
"[[[\"a\", 1, 2, 3, 4, 5, 6]]]",
|
||||
"[[[1, 1, 2, 3, 4]]]",
|
||||
"[[[\"a\", \"1\", 2, 3, 4]]]",
|
||||
"[[[\"a\", 1e999, 2, 3, 4]]]",
|
||||
"[[[\"a\", null, 2, 3, 4]]]",
|
||||
"[[[\"a\", 1, 2, 3, 4, null]]]",
|
||||
"[[[\"a\", 1, 2, 3, 4, \"90\"]]]",
|
||||
] {
|
||||
assert!(matches!(lines_from_json(json, "ocrs"), Err(OcrError::Recognize(_))), "{json}");
|
||||
}
|
||||
let many = format!("[[{}]]", vec!["[\"a\",0,0,1,1]"; MAX_JSON_WORDS + 1].join(","));
|
||||
assert!(lines_from_json(&many, "ocrs").is_err());
|
||||
let deep = format!("{}{}", "[".repeat(10_000), "]".repeat(10_000));
|
||||
assert!(lines_from_json(&deep, "ocrs").is_err());
|
||||
}
|
||||
|
||||
/// A download that isn't a model (an HTML error page, a truncated file) is an error, not a
|
||||
/// crash or a hang.
|
||||
#[test]
|
||||
fn models_from_bad_bytes_are_an_error() {
|
||||
for (detection, recognition) in [(Vec::new(), Vec::new()), (b"<!doctype html>".to_vec(), b"RTEN".to_vec()), (vec![0xff; 64], vec![0; 64])] {
|
||||
assert!(matches!(Ocr::from_bytes(detection, recognition), Err(OcrError::Load(..))));
|
||||
}
|
||||
}
|
||||
|
||||
/// The hashes [`Ocr::from_bytes`] checks are the ones `cargo xtask models` verifies.
|
||||
#[test]
|
||||
fn model_hashes_match_attribution() {
|
||||
let manifest = include_str!("../../../ATTRIBUTION.toml");
|
||||
for (file, sha256) in [(DETECTION_MODEL, DETECTION_MODEL_SHA256), (RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256)] {
|
||||
let entry = manifest
|
||||
.split("[[fetched]]")
|
||||
.find(|e| e.contains(&format!("file = \"{file}\"")))
|
||||
.unwrap_or_else(|| panic!("{file} is not in ATTRIBUTION.toml"));
|
||||
assert!(entry.lines().any(|l| l == format!("sha256 = \"{sha256}\"")), "{file}: SHA-256 differs from ATTRIBUTION.toml");
|
||||
}
|
||||
}
|
||||
|
||||
mod test_image {
|
||||
/// "HELLO WORLD" drawn with blocky 5×7 letters, 4 pixels per dot, black on white.
|
||||
pub fn hello() -> (u32, u32, Vec<u8>) {
|
||||
|
||||
+38
-4
@@ -32,6 +32,15 @@ pub const LANGUAGES: &[(&str, &str)] = &[("en", "English")];
|
||||
/// The model files, as named in ATTRIBUTION.toml.
|
||||
pub const DETECTION_MODEL: &str = "text-detection.onnx";
|
||||
pub const RECOGNITION_MODEL: &str = "text-recognition.onnx";
|
||||
/// Their SHA-256s, as in ATTRIBUTION.toml. [`Ocr::from_bytes`] loads nothing else: rten's ONNX
|
||||
/// parser can spin forever on bytes that aren't a model (64 × 0xff does).
|
||||
pub const DETECTION_MODEL_SHA256: &str = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe";
|
||||
pub const RECOGNITION_MODEL_SHA256: &str = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5";
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(bytes).iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
pub const ALPHABET: &str = " 0123456789!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~€ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
|
||||
|
||||
@@ -84,9 +93,25 @@ pub struct Ocr {
|
||||
impl Ocr {
|
||||
pub fn load(models: &Models) -> Result<Ocr, OcrError> {
|
||||
let load = |p: &Path| rten::Model::load_file(p).map_err(|e| OcrError::Load(p.display().to_string(), e.to_string()));
|
||||
Self::with_models(load(&models.detection)?, load(&models.recognition)?)
|
||||
}
|
||||
|
||||
/// Load the models from the contents of their files, where there is no file system (the web
|
||||
/// build downloads them). Bytes that aren't the files ATTRIBUTION.toml names are an error.
|
||||
pub fn from_bytes(detection: Vec<u8>, recognition: Vec<u8>) -> Result<Ocr, OcrError> {
|
||||
let load = |name: &str, sha256: &str, data: Vec<u8>| {
|
||||
if sha256_hex(&data) != sha256 {
|
||||
return Err(OcrError::Load(name.into(), "not the expected model file (SHA-256 differs)".into()));
|
||||
}
|
||||
rten::Model::load(data).map_err(|e| OcrError::Load(name.into(), e.to_string()))
|
||||
};
|
||||
Self::with_models(load(DETECTION_MODEL, DETECTION_MODEL_SHA256, detection)?, load(RECOGNITION_MODEL, RECOGNITION_MODEL_SHA256, recognition)?)
|
||||
}
|
||||
|
||||
fn with_models(detection: rten::Model, recognition: rten::Model) -> Result<Ocr, OcrError> {
|
||||
let params = ocrs::OcrEngineParams {
|
||||
detection_model: Some(load(&models.detection)?),
|
||||
recognition_model: Some(load(&models.recognition)?),
|
||||
detection_model: Some(detection),
|
||||
recognition_model: Some(recognition),
|
||||
alphabet: Some(ALPHABET.into()),
|
||||
..Default::default()
|
||||
};
|
||||
@@ -139,12 +164,21 @@ impl Ocr {
|
||||
/// The ocrs engine as a [`Recognizer`] (the default engine).
|
||||
pub struct OcrsRecognizer {
|
||||
engine: Ocr,
|
||||
/// Loaded from bytes ([`OcrsRecognizer::from_ocr`]), not from model files: it stays
|
||||
/// available without them.
|
||||
in_memory: bool,
|
||||
}
|
||||
|
||||
impl OcrsRecognizer {
|
||||
/// Load the models from `models`.
|
||||
pub fn load(models: &Models) -> Result<OcrsRecognizer, OcrError> {
|
||||
Ok(OcrsRecognizer { engine: Ocr::load(models)? })
|
||||
Ok(OcrsRecognizer { engine: Ocr::load(models)?, in_memory: false })
|
||||
}
|
||||
|
||||
/// A recogniser loaded some other way than from model files: the web build downloads the
|
||||
/// models and loads them with [`Ocr::from_bytes`].
|
||||
pub fn from_ocr(engine: Ocr) -> OcrsRecognizer {
|
||||
OcrsRecognizer { engine, in_memory: true }
|
||||
}
|
||||
|
||||
/// Load the models found by [`Models::find`].
|
||||
@@ -159,7 +193,7 @@ impl Recognizer for OcrsRecognizer {
|
||||
}
|
||||
|
||||
fn available(&self) -> bool {
|
||||
Models::find().is_some()
|
||||
self.in_memory || Models::find().is_some()
|
||||
}
|
||||
|
||||
fn languages(&self) -> Vec<(String, String)> {
|
||||
|
||||
@@ -13,6 +13,8 @@ aho-corasick = "1"
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-content = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
# The usable-quad rule `/QuadPoints` readers share.
|
||||
pdfcraft-geom = { workspace = true }
|
||||
pdfcraft-model = { workspace = true }
|
||||
pdfcraft-forms = { workspace = true }
|
||||
pdfcraft-edit = { workspace = true }
|
||||
|
||||
@@ -185,6 +185,14 @@ pub(crate) fn rect_of(doc: &Document, o: Option<&Object>) -> Option<[f64; 4]> {
|
||||
(v.len() == 4 && v.iter().all(|x| x.is_finite())).then(|| [v[0].min(v[2]), v[1].min(v[3]), v[0].max(v[2]), v[1].max(v[3])])
|
||||
}
|
||||
|
||||
/// A mark's usable `/QuadPoints` quads ([`pdfcraft_geom::quad_points::usable_quads`]), the set the
|
||||
/// comment outlines show. Each one is finite, so the box it folds to is a real one.
|
||||
fn quad_points(doc: &Document, d: &Dict) -> Vec<[f64; 8]> {
|
||||
let Some(q) = d.get(b"QuadPoints").map(|q| doc.resolve(q)) else { return Vec::new() };
|
||||
let Some(a) = q.as_array() else { return Vec::new() };
|
||||
pdfcraft_geom::quad_points::usable_quads(a.iter().map(|x| doc.resolve(x).as_f64()))
|
||||
}
|
||||
|
||||
fn color(doc: &Document, d: &Dict, key: &[u8]) -> Option<Rgb> {
|
||||
let v: Vec<f64> = doc.resolve(d.get(key)?).as_array()?.iter().filter_map(Object::as_f64).collect();
|
||||
match v.len() {
|
||||
@@ -229,13 +237,7 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
|
||||
if d.name(b"Subtype") != Some(b"Redact") {
|
||||
continue;
|
||||
}
|
||||
let quads: Vec<f64> = d
|
||||
.get(b"QuadPoints")
|
||||
.and_then(|q| doc.resolve(q).as_array().map(|a| a.iter().filter_map(Object::as_f64).collect()))
|
||||
.unwrap_or_default();
|
||||
let mut rects: Vec<[f64; 4]> = quads
|
||||
.as_chunks::<8>()
|
||||
.0
|
||||
let mut rects: Vec<[f64; 4]> = quad_points(doc, d)
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let xs = [q[0], q[2], q[4], q[6]];
|
||||
@@ -247,8 +249,9 @@ pub(crate) fn marks_checked(doc: &Document) -> (Vec<Mark>, Vec<usize>) {
|
||||
ys.iter().copied().fold(f64::MIN, f64::max),
|
||||
]
|
||||
})
|
||||
.filter(|r| r.iter().all(|v| v.is_finite()))
|
||||
.collect();
|
||||
// No usable quad (all absent, malformed or non-finite): the mark's `/Rect`, else it
|
||||
// is unreadable and apply refuses it.
|
||||
if rects.is_empty()
|
||||
&& let Some(r) = rect_of(doc, d.get(b"Rect"))
|
||||
{
|
||||
|
||||
@@ -2405,6 +2405,165 @@ fn an_unreadable_mark_on_another_page_does_not_stop_the_selected_page() {
|
||||
assert!(!content(&doc, 0).contains("1234"));
|
||||
}
|
||||
|
||||
/// The quad over "1234" in `AB1234CD` (x 20–40) and the one over "CD" (x 40–50).
|
||||
const OVER_1234: [f64; 8] = [20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0];
|
||||
const OVER_CD: [f64; 8] = [40.0, 110.0, 50.0, 110.0, 40.0, 95.0, 50.0, 95.0];
|
||||
|
||||
/// `AB1234CD` with one mark whose `/QuadPoints` is `quads`, whose `/Rect` is `rect` (removed when
|
||||
/// `None`) and which has a fill (`/IC`) only when `fill` is set.
|
||||
fn quad_mark(quads: Vec<Object>, rect: Option<[i64; 4]>, fill: bool) -> (Document, ObjRef) {
|
||||
let mut doc = one_page(b"BT /F1 10 Tf 10 100 Td (AB1234CD) Tj ET", "", vec![]);
|
||||
mark(&mut doc, 0, &[[20.0, 95.0, 40.0, 110.0]], "");
|
||||
let m = marks(&doc).swap_remove(0);
|
||||
doc.update_dict(m.obj, |d| {
|
||||
d.set(b"QuadPoints".to_vec(), Object::Array(quads));
|
||||
if let Some(r) = rect {
|
||||
d.set(b"Rect".to_vec(), Object::Array(r.iter().map(|v| Object::Int(*v)).collect()));
|
||||
} else {
|
||||
d.remove(b"Rect");
|
||||
}
|
||||
if !fill {
|
||||
d.remove(b"IC");
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
(doc, m.obj)
|
||||
}
|
||||
|
||||
fn reals(v: &[f64]) -> Vec<Object> {
|
||||
v.iter().map(|x| Object::Real(*x)).collect()
|
||||
}
|
||||
|
||||
/// An all-NaN quad folded to `[MAX, MAX, MIN, MIN]`: a finite, inverted box that passed as a
|
||||
/// readable area. Without a fill the mark was applied and removed nothing; with one the overlay
|
||||
/// failed. With no `/Rect` either, the mark has no area and apply refuses it.
|
||||
#[test]
|
||||
fn an_all_nan_quad_without_a_rect_is_refused_not_applied_as_nothing() {
|
||||
for fill in [false, true] {
|
||||
let (mut doc, _) = quad_mark(reals(&[f64::NAN; 8]), None, fill);
|
||||
assert!(marks(&doc).is_empty(), "fill {fill}: the NaN quad is no area");
|
||||
assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)), "fill {fill}");
|
||||
assert!(content(&doc, 0).contains("(AB1234CD) Tj"), "fill {fill}: nothing changed");
|
||||
}
|
||||
}
|
||||
|
||||
/// With a usable `/Rect`, an all-NaN mark redacts exactly the `/Rect`.
|
||||
#[test]
|
||||
fn an_all_nan_quad_falls_back_to_the_rect() {
|
||||
for fill in [false, true] {
|
||||
let (mut doc, _) = quad_mark(reals(&[f64::NAN; 8]), Some([20, 95, 40, 110]), fill);
|
||||
assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0]], "fill {fill}");
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.marks, r.glyphs), (1, 4), "fill {fill}");
|
||||
assert!(!content(&doc, 0).contains("1234"), "fill {fill}");
|
||||
}
|
||||
}
|
||||
|
||||
/// One NaN among finite entries made `f64::min`/`max` ignore it and fold the other three corners:
|
||||
/// a quad the document never described. Such a quad is unusable.
|
||||
#[test]
|
||||
fn a_quad_with_one_nan_entry_is_unusable() {
|
||||
let mut q = OVER_1234;
|
||||
q[0] = f64::NAN;
|
||||
let (mut doc, _) = quad_mark(reals(&q), None, false);
|
||||
assert!(marks(&doc).is_empty());
|
||||
assert_eq!(apply(&mut doc, None), Err(RedactError::UnreadableMark(1)));
|
||||
// Next to a usable quad, only that one applies.
|
||||
let (mut doc, _) = quad_mark(reals(&[q, OVER_CD].concat()), None, false);
|
||||
assert_eq!(marks(&doc)[0].rects, vec![[40.0, 95.0, 50.0, 110.0]]);
|
||||
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(2));
|
||||
}
|
||||
|
||||
/// A name in the middle of `/QuadPoints` was dropped, which moved every later coordinate into the
|
||||
/// wrong slot and folded the first quad to a box over most of the page.
|
||||
#[test]
|
||||
fn a_non_number_entry_drops_its_quad_without_shifting_the_rest() {
|
||||
let mut quads = reals(&[OVER_1234, OVER_CD].concat());
|
||||
quads[2] = Object::name("oops");
|
||||
let (mut doc, _) = quad_mark(quads, None, false);
|
||||
assert_eq!(marks(&doc)[0].rects, vec![[40.0, 95.0, 50.0, 110.0]]);
|
||||
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(2));
|
||||
}
|
||||
|
||||
/// Twelve entries are one quad and half of another: only the whole one applies.
|
||||
#[test]
|
||||
fn a_trailing_partial_quad_is_ignored() {
|
||||
let (mut doc, _) = quad_mark(reals(&[&OVER_1234[..], &OVER_CD[..4]].concat()), None, false);
|
||||
assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0]]);
|
||||
assert_eq!(apply(&mut doc, None).map(|r| r.glyphs), Ok(4));
|
||||
}
|
||||
|
||||
/// Finite quads redact what they always did, one box each, and the `/Rect` is not consulted.
|
||||
#[test]
|
||||
fn finite_quads_redact_their_areas_as_before() {
|
||||
let (mut doc, _) = quad_mark(reals(&[OVER_1234, OVER_CD].concat()), Some([0, 0, 1, 1]), true);
|
||||
assert_eq!(marks(&doc)[0].rects, vec![[20.0, 95.0, 40.0, 110.0], [40.0, 95.0, 50.0, 110.0]]);
|
||||
let r = apply(&mut doc, None).unwrap();
|
||||
assert_eq!((r.marks, r.glyphs), (1, 6));
|
||||
assert!(!content(&doc, 0).contains("1234"));
|
||||
}
|
||||
|
||||
/// The boxes a reader's quads outline, or its `/Rect` when it has none (as the comment outlines
|
||||
/// fall back to it).
|
||||
fn outline(quads: &[[f32; 8]], rect: [f32; 4]) -> Vec<[f32; 4]> {
|
||||
if quads.is_empty() {
|
||||
return vec![rect];
|
||||
}
|
||||
quads
|
||||
.iter()
|
||||
.map(|q| {
|
||||
let (xs, ys) = ([q[0], q[2], q[4], q[6]], [q[1], q[3], q[5], q[7]]);
|
||||
let (lo, hi) = (|v: [f32; 4]| v.into_iter().fold(f32::MAX, f32::min), |v: [f32; 4]| v.into_iter().fold(f32::MIN, f32::max));
|
||||
[lo(xs), lo(ys), hi(xs), hi(ys)]
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The outline the comment list draws (`pdfcraft_annot::summaries`, and the inspector's listing
|
||||
/// of the saved file) is the area apply removes, for malformed `/QuadPoints` too. The readers
|
||||
/// used to disagree: the summaries and the inspector read non-numbers as zeros, apply dropped
|
||||
/// them.
|
||||
#[test]
|
||||
fn the_outline_shown_is_the_area_apply_removes() {
|
||||
// (entries, whether a saved file can say them: PDF syntax has no NaN). `forty` is an indirect
|
||||
// number, which every reader follows.
|
||||
let cases = |forty: ObjRef| -> Vec<(Vec<Object>, bool)> {
|
||||
let mut name_inside = reals(&[OVER_1234, OVER_CD].concat());
|
||||
name_inside[2] = Object::name("oops");
|
||||
let mut by_reference = reals(&OVER_1234);
|
||||
by_reference[2] = Object::Ref(forty);
|
||||
let mut one_nan = OVER_1234;
|
||||
one_nan[5] = f64::NAN;
|
||||
let mut one_huge = OVER_1234;
|
||||
one_huge[5] = 4e38;
|
||||
vec![
|
||||
(reals(&[OVER_1234, OVER_CD].concat()), true),
|
||||
(name_inside, true),
|
||||
(reals(&[&OVER_1234[..], &OVER_CD[..4]].concat()), true),
|
||||
(by_reference, true),
|
||||
(vec![Object::name("oops"); 8], true),
|
||||
(reals(&[one_nan, OVER_CD].concat()), false),
|
||||
(reals(&[one_huge, OVER_CD].concat()), true),
|
||||
(reals(&[f64::NAN; 8]), false),
|
||||
]
|
||||
};
|
||||
for i in 0.. {
|
||||
let (mut doc, obj) = quad_mark(Vec::new(), Some([20, 95, 40, 110]), true);
|
||||
let forty = doc.add(Object::Int(40));
|
||||
let Some((quads, saved)) = cases(forty).into_iter().nth(i) else { break };
|
||||
doc.update_dict(obj, |d| d.set(b"QuadPoints".to_vec(), Object::Array(quads))).unwrap();
|
||||
let applied: Vec<[f32; 4]> = marks(&doc)[0].rects.iter().map(|r| r.map(|v| v as f32)).collect();
|
||||
let summary = pdfcraft_annot::summaries(&doc).into_iter().find(|s| s.subtype == "Redact").unwrap();
|
||||
assert_eq!(outline(&summary.quads, summary.rect), applied, "case {i}: comment summaries");
|
||||
if saved {
|
||||
let bytes = write_incremental(&doc, &SaveOptions::default()).unwrap();
|
||||
let info = pdfcraft_render::inspect(Arc::new(bytes), None).unwrap();
|
||||
let listed = info.annotations.iter().find(|a| a.subtype == "Redact").unwrap();
|
||||
assert_eq!(outline(&listed.quads, listed.rect), applied, "case {i}: inspector");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A document with something in every hidden-information category.
|
||||
fn hidden_fixture() -> Document {
|
||||
let content = b"BT /F1 10 Tf 10 100 Td (Visible) Tj 3 Tr (Hidden) Tj 0 Tr ET BT /F1 10 Tf 500 500 Td (Offpage) Tj ET /OC /L1 BDC BT /F1 10 Tf 10 50 Td (Layer) Tj ET EMC";
|
||||
|
||||
@@ -11,7 +11,9 @@ workspace = true
|
||||
[dependencies]
|
||||
pdfcraft-cos = { workspace = true }
|
||||
pdfcraft-fonts = { workspace = true }
|
||||
hayro = { workspace = true }
|
||||
# The usable-quad rule `/QuadPoints` readers share.
|
||||
pdfcraft-geom = { workspace = true }
|
||||
hayro ={ workspace = true }
|
||||
hayro-syntax = { workspace = true }
|
||||
# Picks the face of an installed font collection (the version pdfcraft-fonts already uses).
|
||||
skrifa = "0.44"
|
||||
|
||||
@@ -138,10 +138,12 @@ impl<'a> Inspector<'a> {
|
||||
color = Some([f(i - 3), f(i - 2), f(i - 1)]);
|
||||
}
|
||||
}
|
||||
// Only usable quads, by the rule redaction apply reads them with (keep in step with
|
||||
// `pdfcraft_annot::summaries`): a malformed one is not drawn as zeros.
|
||||
let quads = match d.get(b"QuadPoints").map(|o| self.resolve(o)) {
|
||||
Ok(Object::Array(q)) => {
|
||||
let v: Vec<f32> = q.iter().map(|o| o.as_float().unwrap_or(0.0)).collect();
|
||||
v.as_chunks::<8>().0.to_vec()
|
||||
let entries = q.iter().map(|o| self.resolve(o).as_float().ok().map(f64::from));
|
||||
pdfcraft_geom::quad_points::usable_quads(entries).into_iter().map(|quad| quad.map(|v| v as f32)).collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
};
|
||||
|
||||
@@ -806,6 +806,26 @@ impl Structure for CountingReads<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `/QuadPoints` is read by the rule redaction apply uses (`pdfcraft_geom::quad_points`): a quad
|
||||
/// with a non-number is left out rather than drawn with a zero, an indirect number is followed,
|
||||
/// and a trailing partial quad is dropped.
|
||||
#[test]
|
||||
fn only_usable_quads_are_listed() {
|
||||
let pdf = "%PDF-1.7\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n\
|
||||
2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n\
|
||||
3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Annots [5 0 R] >> endobj\n\
|
||||
4 0 obj 40 endobj\n\
|
||||
5 0 obj << /Type /Annot /Subtype /Redact /Rect [20 95 40 110] /QuadPoints \
|
||||
[20 /oops 40 110 20 95 40 95 20 110 4 0 R 110 20 95 40 95 20 110 40 110] >> endobj\n\
|
||||
trailer << /Root 1 0 R >>\n%%EOF";
|
||||
let doc = Document::load_mem(pdf.as_bytes()).expect("loads");
|
||||
let page = PageInfo { width: 200.0, height: 200.0, crop: [0.0, 0.0, 200.0, 200.0], rotation: 0, label: String::new() };
|
||||
let mut info = DocInfo { pages: vec![page], ..Default::default() };
|
||||
Inspector::new(&doc).fill(&mut info);
|
||||
let mark = info.annotations.iter().find(|a| a.subtype == "Redact").expect("listed");
|
||||
assert_eq!(mark.quads, vec![[20.0, 110.0, 40.0, 110.0, 20.0, 95.0, 40.0, 95.0]]);
|
||||
}
|
||||
|
||||
/// One comment referenced many times, or a page-shared `/Annots` array, is listed once, and many
|
||||
/// small comments sharing one large `/Contents` string stop at the retained-bytes budget.
|
||||
#[test]
|
||||
|
||||
@@ -88,7 +88,7 @@ rfd = { workspace = true, features = ["file-handle-inner"] }
|
||||
wasm-bindgen-futures = "0.4"
|
||||
wasm-bindgen = "0.2"
|
||||
js-sys = "0.3"
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator"] }
|
||||
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "HtmlIFrameElement", "HtmlImageElement", "HtmlCollection", "Node", "EventTarget", "Navigator", "RequestCache", "RequestInit", "Response", "Worker", "WorkerOptions", "WorkerType", "MessageEvent"] }
|
||||
|
||||
[dev-dependencies]
|
||||
craft-update = { workspace = true }
|
||||
|
||||
@@ -203,6 +203,10 @@ impl PdfCraftApp {
|
||||
ocr_draft: ocr_ui::OcrDraft::default(),
|
||||
ocr_run: None,
|
||||
ocr_batch: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_batch: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_waiting: None,
|
||||
ocr_verify: ocr_verify::OcrVerify::default(),
|
||||
run_inline: false,
|
||||
custom_actions: Vec::new(),
|
||||
@@ -590,7 +594,8 @@ impl eframe::App for PdfCraftApp {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.watch_files();
|
||||
if self.host_dirty.is_some() {
|
||||
let dirty = self.first_dirty().is_some();
|
||||
// An OCR review not yet accepted or rejected is work the page would lose, too.
|
||||
let dirty = self.work_at_risk();
|
||||
if let Some(report) = self.host_dirty.as_mut() {
|
||||
report(dirty);
|
||||
}
|
||||
@@ -641,6 +646,11 @@ impl eframe::App for PdfCraftApp {
|
||||
if self.ocr_verify.phase == workflows::ocr_verify::Phase::Running {
|
||||
workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll);
|
||||
}
|
||||
// A run or re-read asked for while the web build downloaded the models starts when they land.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.ocr_verify.awaiting_models.is_some() {
|
||||
workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll_models);
|
||||
}
|
||||
// So may a page or region re-read's worker.
|
||||
if self.ocr_verify.reread.is_some() {
|
||||
workflows::ocr_verify::with_state(self, workflows::ocr_verify::OcrVerify::poll_reread);
|
||||
@@ -701,10 +711,20 @@ impl eframe::App for PdfCraftApp {
|
||||
if self.draws_window_frame() {
|
||||
window_controls::resize_edges(&ctx);
|
||||
}
|
||||
chrome::tab_strip(self, ui);
|
||||
chrome::mode_bar(self, ui);
|
||||
// Another mode or the Home tab puts the OCR Verify screen away, as Exit does; Correct
|
||||
// recognized text brings it back with its review as it was.
|
||||
if self.ocr_verify.open && (self.active.is_none() || self.ocr_verify.shown_in.is_some_and(|m| m != self.mode)) {
|
||||
self.ocr_verify.open = false;
|
||||
}
|
||||
self.ocr_verify.shown_in = self.ocr_verify.open.then_some(self.mode);
|
||||
if self.ocr_verify.open {
|
||||
// The OCR Verify screen owns the window (its four panes); dialogs and notices
|
||||
// The OCR Verify screen takes the document area (its four panes) under the window's
|
||||
// tabs, menus and toolbar, which stay (#6485); the palette, dialogs and notices
|
||||
// still show above it.
|
||||
workflows::ocr_verify::show(self, ui);
|
||||
palette::show(self, &ctx);
|
||||
dialogs::show(self, &ctx);
|
||||
self.show_progress(&ctx);
|
||||
widgets::toast(self, &ctx);
|
||||
@@ -715,8 +735,6 @@ impl eframe::App for PdfCraftApp {
|
||||
}
|
||||
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.
|
||||
if self.all_tools_full && self.all_tools_tab != self.shown_tab() {
|
||||
self.all_tools_full = false;
|
||||
|
||||
@@ -636,6 +636,10 @@ pub struct DocView {
|
||||
/// Pages Edit looked at and found not to be a scan, with the document's edit generation then:
|
||||
/// they are looked at again only after the document changes (an image added, say).
|
||||
pub(crate) auto_ocr_skipped: std::collections::HashMap<usize, u64>,
|
||||
/// Scans Edit found while no engine could read them, with the document's edit generation
|
||||
/// then: they wait for one (the web build downloads the models when first needed), and are
|
||||
/// read once the models are in (see `PdfCraftApp::ocr_models_loaded`) or the document changes.
|
||||
pub(crate) auto_ocr_awaiting_models: std::collections::HashMap<usize, u64>,
|
||||
/// Turns wheel input into page turns in single-page view.
|
||||
wheel: crate::wheel_pager::WheelPager,
|
||||
/// The document owns the full coarse-wheel distance, including its acceleration.
|
||||
@@ -658,6 +662,10 @@ pub struct DocView {
|
||||
/// Edit text: the lines per page (with the document generation they were read at), and the
|
||||
/// line being edited.
|
||||
pub(crate) edit_lines: HashMap<usize, (u64, Vec<pdfcraft_engine::TextBlock>)>,
|
||||
/// Edit text: a click on grouped artwork with text queued [`Edit::PrepareText`] for its page:
|
||||
/// the page and the point clicked (user space), whose paragraph opens once the page is
|
||||
/// prepared, so one click on grouped text starts editing it.
|
||||
pub(crate) grouped_text_click: Option<(usize, [f64; 2])>,
|
||||
pub line_editor: Option<crate::edit_text_ui::LineEditor>,
|
||||
/// The paragraph editor whose edit was just queued. It opens again with its typing if the
|
||||
/// edit fails, as a refused form field does.
|
||||
@@ -1047,6 +1055,7 @@ impl DocView {
|
||||
shown: false,
|
||||
auto_ocr_tried: Default::default(),
|
||||
auto_ocr_skipped: Default::default(),
|
||||
auto_ocr_awaiting_models: Default::default(),
|
||||
wheel: Default::default(),
|
||||
wheel_motion: Default::default(),
|
||||
wheel_frame: None,
|
||||
@@ -1059,6 +1068,7 @@ impl DocView {
|
||||
organize_range: Default::default(),
|
||||
pending_edit: None,
|
||||
edit_lines: HashMap::new(),
|
||||
grouped_text_click: None,
|
||||
line_editor: None,
|
||||
applied_editor: None,
|
||||
pending_text_open: None,
|
||||
@@ -2468,6 +2478,9 @@ fn close_requested_tab(app: &mut PdfCraftApp, index: usize) -> bool {
|
||||
let pending = app.views.get(index).filter(|v| v.close_requested).map(|v| v.id).and_then(|id| app.views.iter().position(|v| v.id == id));
|
||||
let Some(i) = pending else { return false };
|
||||
app.views[i].close_requested = false;
|
||||
if app.hold_close_for_ocr_review(i) {
|
||||
return true;
|
||||
}
|
||||
if app.has_unsaved_work(i) {
|
||||
app.close_request = Some(crate::CloseRequest::Tab(app.views[i].id));
|
||||
} else {
|
||||
|
||||
@@ -219,7 +219,7 @@ pub(super) fn page_tool_input(ui: &mut egui::Ui, view: &mut DocView, frame: &mut
|
||||
let adding = (tool == QuickTool::AddText).then_some(added.as_slice());
|
||||
let pcx = crate::edit_text_ui::ParagraphCx { press, gate, hover: hovered.as_ref(), adding };
|
||||
let text_used = crate::edit_text_ui::page_input(cx, &lines, pcx, view);
|
||||
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, info, press, gate };
|
||||
let image_cx = crate::edit_text_ui::ImageCx { ui, resp, xf: &xf, ptr, page: i, doc, info, press, gate };
|
||||
let image_used = crate::edit_text_ui::image_input(&image_cx, &images, view);
|
||||
text_used || image_used
|
||||
};
|
||||
|
||||
@@ -14,8 +14,12 @@ pub enum CloseRequest {
|
||||
}
|
||||
|
||||
impl PdfCraftApp {
|
||||
/// Close a tab, asking first if it has unsaved changes.
|
||||
/// Close a tab, asking first if it has unsaved changes. A document under OCR review stays
|
||||
/// open until the review is accepted or rejected.
|
||||
pub fn request_close_tab(&mut self, index: usize) {
|
||||
if self.hold_close_for_ocr_review(index) {
|
||||
return;
|
||||
}
|
||||
// Text typed into a field counts as an unsaved change.
|
||||
if self.has_unsaved_work(index)
|
||||
&& let Some(id) = self.views.get(index).map(|v| v.id)
|
||||
@@ -27,7 +31,11 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
/// File ▸ Close all: clean documents close at once; each one with unsaved changes asks.
|
||||
/// While a document is under OCR review, nothing closes: the review comes forward first.
|
||||
pub fn close_all(&mut self) {
|
||||
if self.hold_for_any_ocr_review() {
|
||||
return;
|
||||
}
|
||||
for i in (0..self.views.len()).rev() {
|
||||
if !self.has_unsaved_work(i) {
|
||||
self.close_tab(i);
|
||||
@@ -38,6 +46,33 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A document whose OCR review is still open (a run, or a reading not yet accepted or
|
||||
/// rejected) does not close under it: closing would leave the review with nothing to apply
|
||||
/// to (#6485). The document comes to the front with its review, and a notice says what to
|
||||
/// do. Whether the close was held.
|
||||
pub(crate) fn hold_close_for_ocr_review(&mut self, index: usize) -> bool {
|
||||
let Some(id) = self.views.get(index).map(|v| v.id) else { return false };
|
||||
if !self.ocr_verify.holds_reading_of(id) {
|
||||
return false;
|
||||
}
|
||||
self.active = Some(index);
|
||||
self.ocr_verify.open = true;
|
||||
self.notify_tr("Accept or reject the OCR review before closing this document");
|
||||
true
|
||||
}
|
||||
|
||||
/// Close all and Quit close every document, so a document under OCR review holds them too
|
||||
/// ([`Self::hold_close_for_ocr_review`]), even when it has no unsaved changes. Whether they
|
||||
/// were held.
|
||||
fn hold_for_any_ocr_review(&mut self) -> bool {
|
||||
self.ocr_review_index().is_some_and(|i| self.hold_close_for_ocr_review(i))
|
||||
}
|
||||
|
||||
/// The tab of the document whose OCR review is still open, if any.
|
||||
fn ocr_review_index(&self) -> Option<usize> {
|
||||
self.views.iter().position(|v| self.ocr_verify.holds_reading_of(v.id))
|
||||
}
|
||||
|
||||
/// File ▸ Revert (after the confirmation).
|
||||
pub fn revert_active(&mut self) {
|
||||
let Some((_, id)) = self.active_ids() else { return };
|
||||
@@ -116,11 +151,21 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
/// Close tab `index` for a close request, then ask about the next dirty document, or quit.
|
||||
pub(super) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) {
|
||||
/// The save it waited for may have taken a later frame (the user choosing where), and an OCR
|
||||
/// review started meanwhile holds the close as it holds a new one: the document stays open,
|
||||
/// and Quit or Close all stops there.
|
||||
pub(crate) fn close_and_continue(&mut self, ctx: &egui::Context, index: usize, req: CloseRequest) {
|
||||
let whole = req == CloseRequest::Quit || req == CloseRequest::All;
|
||||
if self.hold_close_for_ocr_review(index) || (whole && self.hold_for_any_ocr_review()) {
|
||||
if req == CloseRequest::Quit {
|
||||
self.forget_quit_session();
|
||||
}
|
||||
return;
|
||||
}
|
||||
self.close_tab(index);
|
||||
// A newer prompt wins: the user may have started another close while a save was pending,
|
||||
// and seen its question, which keeps counting as seen.
|
||||
if (req == CloseRequest::Quit || req == CloseRequest::All) && self.close_request.is_none() {
|
||||
if whole && self.close_request.is_none() {
|
||||
match self.first_dirty() {
|
||||
Some(_) => {
|
||||
// A question about the next document is a new one.
|
||||
@@ -134,9 +179,13 @@ impl PdfCraftApp {
|
||||
}
|
||||
|
||||
/// Request a normal application exit, preserving every unsaved document until answered.
|
||||
/// Returns false while the existing save/discard/cancel prompt needs a decision.
|
||||
/// Returns false while the existing save/discard/cancel prompt needs a decision, or while an
|
||||
/// OCR review is still to be accepted or rejected.
|
||||
pub(crate) fn request_quit(&mut self, ctx: &egui::Context) -> bool {
|
||||
if self.first_dirty().is_some() {
|
||||
if self.hold_for_any_ocr_review() {
|
||||
ctx.request_repaint();
|
||||
false
|
||||
} else if self.first_dirty().is_some() {
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
ctx.request_repaint();
|
||||
false
|
||||
@@ -152,12 +201,21 @@ impl PdfCraftApp {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::Close);
|
||||
}
|
||||
|
||||
/// Publish whether any document has unsaved work, for the browser's reload warning (#812).
|
||||
/// Publish whether leaving would lose work, for the browser's reload warning (#812): a
|
||||
/// document with unsaved changes, or an OCR review not yet accepted or rejected, which holds
|
||||
/// Close and Quit the same way.
|
||||
pub(crate) fn sync_unsaved_flag(&self) {
|
||||
self.unsaved_flag.store(self.first_dirty().is_some(), std::sync::atomic::Ordering::Relaxed);
|
||||
self.unsaved_flag.store(self.work_at_risk(), std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Intercept window close while documents have unsaved changes.
|
||||
/// Whether leaving would lose work: a document with unsaved changes, or an OCR review not yet
|
||||
/// accepted or rejected. The browser's reload warning and the embedding page (`host_dirty`)
|
||||
/// both hear this.
|
||||
pub(crate) fn work_at_risk(&self) -> bool {
|
||||
self.first_dirty().is_some() || self.ocr_review_index().is_some()
|
||||
}
|
||||
|
||||
/// Intercept window close while documents have unsaved changes or an OCR review is open.
|
||||
pub(crate) fn guard_quit(&mut self, ctx: &egui::Context) {
|
||||
// egui's default quit shortcut ignores extra Alt/Shift, including Ctrl+Alt+Q for '@'.
|
||||
ctx.options_mut(|o| o.quit_shortcuts.clear());
|
||||
@@ -174,8 +232,10 @@ impl PdfCraftApp {
|
||||
if !ctx.input(|i| i.viewport().close_requested()) {
|
||||
return;
|
||||
}
|
||||
// `first_dirty` counts text typed into a field as an unsaved change.
|
||||
if !self.allow_quit && self.first_dirty().is_some() {
|
||||
if !self.allow_quit && self.hold_for_any_ocr_review() {
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
} else if !self.allow_quit && self.first_dirty().is_some() {
|
||||
// `first_dirty` counts text typed into a field as an unsaved change.
|
||||
ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose);
|
||||
self.close_request = Some(CloseRequest::Quit);
|
||||
} else {
|
||||
|
||||
@@ -23,5 +23,6 @@ pub type OsEventsFn = Box<dyn FnMut() -> Vec<OsEvent>>;
|
||||
/// for saves; without it the browser downloads the file.
|
||||
pub type HostSaveFn = Box<dyn Fn(&str, &[u8], bool) -> Result<(), String>>;
|
||||
|
||||
/// Told every frame whether any tab has unsaved work, so a host page can warn before leaving.
|
||||
/// Told every frame whether any tab has unsaved work, or an OCR review is not yet accepted or
|
||||
/// rejected, so a host page can warn before leaving.
|
||||
pub type HostDirtyFn = Box<dyn FnMut(bool)>;
|
||||
|
||||
@@ -187,8 +187,12 @@ use workflows::create_ui;
|
||||
use workflows::export_ui;
|
||||
use workflows::files;
|
||||
use workflows::js_ui;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_route;
|
||||
use workflows::ocr_ui;
|
||||
use workflows::ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
use workflows::ocr_web;
|
||||
use workflows::optimize_ui;
|
||||
use workflows::print_ui;
|
||||
use workflows::scan_clean_ui;
|
||||
@@ -467,13 +471,13 @@ pub struct PdfCraftApp {
|
||||
startup_superseded: bool,
|
||||
/// Asynchronous opens that failed (web `?file=` fetches), shown as a notice.
|
||||
pub failed_inbox: FailedInbox,
|
||||
/// Mirrors "some document has unsaved work" each frame, so the web page's `beforeunload` handler can read it (#812).
|
||||
/// Mirrors "some document has unsaved work, or an OCR review is open" each frame, so the web page's `beforeunload` handler can read it (#812).
|
||||
pub unsaved_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
|
||||
/// Requests from the operating system, polled every frame (macOS Apple events).
|
||||
pub os_events: Option<OsEventsFn>,
|
||||
/// Save delivers to the embedding page instead of downloading (web, see [`HostSaveFn`]).
|
||||
pub host_save: Option<HostSaveFn>,
|
||||
/// Reports unsaved work to the embedding page (web, see [`HostDirtyFn`]).
|
||||
/// Reports unsaved work, or an open OCR review, to the embedding page (web, see [`HostDirtyFn`]).
|
||||
pub host_dirty: Option<HostDirtyFn>,
|
||||
/// A pending "save changes?" question (closing a dirty tab or quitting).
|
||||
pub close_request: Option<CloseRequest>,
|
||||
@@ -524,6 +528,13 @@ pub struct PdfCraftApp {
|
||||
pub ocr_draft: ocr_ui::OcrDraft,
|
||||
pub ocr_run: Option<ocr_ui::OcrRun>,
|
||||
pub ocr_batch: Option<std::sync::Arc<std::sync::Mutex<ocr_ui::BatchProgress>>>,
|
||||
/// The web build's files being read in Web Workers (progress in `ocr_batch`).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_batch: Option<ocr_web::Batch>,
|
||||
/// The web build's files chosen before the text recognition models were downloaded, with
|
||||
/// the settings they were chosen with: read once the models are ready.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
ocr_web_waiting: Option<ocr_ui::WaitingFiles>,
|
||||
/// Scan & OCR ▸ Correct recognized text: the OCR Verify screen (its state, never persisted).
|
||||
pub ocr_verify: ocr_verify::OcrVerify,
|
||||
/// Background jobs (OCR, actions, listing pinned folders) run inline instead (tests).
|
||||
|
||||
@@ -707,6 +707,16 @@ pub(crate) fn page_input(cx: PageToolContext<'_>, lines: &[pdfcraft_engine::Text
|
||||
None => view.pending_text_open = None,
|
||||
}
|
||||
}
|
||||
// A click on grouped text prepared the page (`image_input`): the paragraph now at the point
|
||||
// clicked opens, and none if the point is on the group's artwork. A preparation that failed
|
||||
// left the page as it was, where no paragraph is at that point. It is taken either way.
|
||||
if let Some((_, [x, y])) = view.grouped_text_click.take_if(|(p, _)| *p == page)
|
||||
&& gate
|
||||
&& active.is_none()
|
||||
&& let Some((block, _)) = grab_at(&boxes, user_to_screen(xf, info, page, x, y), false)
|
||||
{
|
||||
open_editor(cx, lines, &boxes, block, view);
|
||||
}
|
||||
if !gate {
|
||||
return false;
|
||||
}
|
||||
@@ -1345,6 +1355,49 @@ C", &picked
|
||||
assert_eq!(doc.text_lines(0).iter().map(|l| l.text.as_str()).collect::<String>(), "Text preview");
|
||||
}
|
||||
|
||||
/// Typing in a recognised phrase whose words OCR sized at 15, 9 and 11 pt lays every word out
|
||||
/// at the phrase's one size, as the engine redraws it (#3313); each word used its own.
|
||||
#[test]
|
||||
fn typing_in_an_ocr_phrase_lays_every_word_out_at_one_size() {
|
||||
use pdfcraft_engine::ocr::PlacedWord;
|
||||
// A word box as OCR draws it around capitals: the cap height of a `size` pt word.
|
||||
let word = |text: &str, x0: f64, x1: f64, size: f64| PlacedWord {
|
||||
text: text.into(),
|
||||
origin: [x0, 70.0],
|
||||
across: [x1 - x0, 0.0],
|
||||
up: [0.0, 0.718 * size],
|
||||
confidence: None,
|
||||
source: "ocrs".into(),
|
||||
};
|
||||
let mut app = crate::PdfCraftApp::new();
|
||||
let png = pdfcraft_engine::export::encode_png(200, 100, &[235u8; 200 * 100 * 4]).unwrap();
|
||||
let bytes = app.session.create_from_images(&[("scan.png".into(), png)]).unwrap();
|
||||
app.open_bytes("scan.pdf", None, bytes.as_ref().clone()).unwrap();
|
||||
let id = app.views[0].id;
|
||||
let words = vec![word("JOHN", 20.0, 45.0, 15.0), word("SMITH", 49.0, 78.0, 9.0), word("EXAMPLE", 82.0, 124.0, 11.0)];
|
||||
app.session.apply(id, Edit::AddOcrText { page: 0, words }).unwrap();
|
||||
let doc = app.session.get(id).unwrap();
|
||||
let block = doc.text_blocks(0).remove(0);
|
||||
assert!(block.invisible && block.lines.len() == 3, "one phrase of three recognised words: {block:?}");
|
||||
let (_, mut ed) = preview_app();
|
||||
ed.text = format!("{}S", block.editor_text);
|
||||
ed.original = block.editor_text.clone();
|
||||
ed.look = look_of(&block);
|
||||
ed.look0 = look_of(&block);
|
||||
ed.source = Some((block, doc.text_lines(0)));
|
||||
|
||||
let ctx = egui::Context::default();
|
||||
let mut sizes = Vec::new();
|
||||
ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
let job = editor_job(ui, &ed, &ed.text, 1.0, f32::INFINITY);
|
||||
sizes = job.sections.iter().map(|section| section.format.font_id.size).collect();
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
assert!(sizes.len() >= 3, "a section for each word: {sizes:?}");
|
||||
assert!(sizes.iter().all(|size| (size - 11.0).abs() < 0.05), "every word at the phrase's 11 pt: {sizes:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_paragraph_stays_open_with_its_text() {
|
||||
let (mut app, mut ed) = preview_app();
|
||||
|
||||
@@ -12,6 +12,7 @@ pub(crate) struct ImageCx<'a> {
|
||||
pub xf: &'a PageXform,
|
||||
pub ptr: &'a PagePointer,
|
||||
pub page: usize,
|
||||
pub doc: &'a pdfcraft_engine::Document,
|
||||
pub info: &'a DocInfo,
|
||||
/// What the press the pages hold is to this page.
|
||||
pub press: PagePress,
|
||||
@@ -268,7 +269,7 @@ fn same_rect(a: [f64; 4], b: [f64; 4]) -> bool {
|
||||
/// (the pointer is the crop's while it lasts; its keys are read by [`picture_keys`]).
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
fn crop_input(cx: &ImageCx<'_>, view: &mut DocView, i: usize, image: &pdfcraft_engine::PageImage) -> bool {
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate, .. } = *cx;
|
||||
let painter = ui.painter();
|
||||
let Some(mode) = view.image_selection.as_mut().and_then(|s| s.crop.as_mut()) else { return false };
|
||||
let hover_pos = ptr.pos;
|
||||
@@ -686,7 +687,7 @@ pub(super) fn turn_degrees(centre: Pos2, pointer: Pos2, snap: bool) -> f32 {
|
||||
/// pointer was used.
|
||||
#[allow(clippy::cognitive_complexity)]
|
||||
pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage], view: &mut DocView) -> bool {
|
||||
let ImageCx { ui, resp, xf, ptr, page, info, press, gate } = *cx;
|
||||
let ImageCx { ui, resp, xf, ptr, page, doc, info, press, gate } = *cx;
|
||||
let boxes: Vec<Rect> = images.iter().map(|im| crate::canvas::image_screen_rect(im, xf, info, page)).collect();
|
||||
let painter = ui.painter();
|
||||
let hover_pos = ptr.pos;
|
||||
@@ -977,18 +978,32 @@ pub(crate) fn image_input(cx: &ImageCx<'_>, images: &[pdfcraft_engine::PageImage
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// A click selects the picture its press was on; its cursor is the hit's. One nobody holds on this
|
||||
// page leaves it unselected (where the page's objects can be selected together, the area
|
||||
// selection the press starts has done so already).
|
||||
// A click selects the picture its press was on; its cursor is the hit's. One on grouped
|
||||
// artwork with text prepares the page instead (one undoable step), and the paragraph clicked
|
||||
// opens once it has (`edit_text_ui::page_input`): a drag still moves the group selected. Not
|
||||
// while an edit is queued or a paragraph is open, whose typing that edit would overtake. One
|
||||
// nobody holds on this page leaves the picture unselected (where the page's objects can be
|
||||
// selected together, the area selection the press starts has done so already).
|
||||
let owner = match press {
|
||||
PagePress::Mine(Owner::Picture { index, .. }) => Some(index),
|
||||
_ => None,
|
||||
};
|
||||
if gate && resp.clicked() {
|
||||
match owner {
|
||||
Some(index) => view.image_selection = Some(ImageSelection::at(page, index)),
|
||||
None if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
|
||||
None => {}
|
||||
// Where grouped artwork with text was clicked; the page is read for it once, on the click.
|
||||
let grouped_text = owner
|
||||
.filter(|i| images.get(*i).is_some_and(|im| im.is_form) && view.pending_edit.is_none() && view.line_editor.is_none())
|
||||
.zip(ptr.released.or(ptr.origin))
|
||||
.filter(|(i, _)| doc.grouped_text_at(page, *i));
|
||||
match (owner, grouped_text) {
|
||||
(_, Some((_, at))) => {
|
||||
let (x, y) = screen_to_user(xf, info, page, at);
|
||||
view.image_selection = None;
|
||||
view.pending_edit = Some(Edit::PrepareText { page });
|
||||
view.grouped_text_click = Some((page, [x, y]));
|
||||
}
|
||||
(Some(index), None) => view.image_selection = Some(ImageSelection::at(page, index)),
|
||||
(None, _) if press == PagePress::Free && ptr.released.is_some_and(|p| xf.rect.contains(p)) => view.image_selection = None,
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
owner.is_some()
|
||||
|
||||
@@ -68,7 +68,9 @@ pub(crate) fn editor_fill(text: Color32) -> Color32 {
|
||||
|
||||
/// One TextEdit lays out all source runs; its caret and selection span the whole region.
|
||||
pub(crate) fn install_source_fonts(ctx: &egui::Context, doc: &pdfcraft_engine::Document, page: usize) -> BTreeMap<String, String> {
|
||||
let programs = doc.text_editor_fonts(page);
|
||||
// Bundled replacement selectors are menu choices, never source resource names. Installing
|
||||
// an unused choice would make even a standard-font editor wait a frame for its font atlas.
|
||||
let programs: Vec<_> = doc.text_editor_fonts(page).into_iter().filter(|font| !font.choice_only).collect();
|
||||
let mut definitions = ctx.fonts(|f| f.definitions().clone());
|
||||
// Only the current editor's document fonts live in the atlas. Never accumulate the
|
||||
// programs of previously opened files or add them to the interface's default families.
|
||||
@@ -126,10 +128,14 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
|
||||
if centered {
|
||||
job.halign = egui::Align::Center;
|
||||
}
|
||||
// An OCR phrase is set in one size, as the engine redraws it: each word's own is a guess
|
||||
// from its box. Its words still keep their widths.
|
||||
let phrase = block.phrase_size();
|
||||
for (range, index) in block.edited_spans(text) {
|
||||
let Some(line) = lines.get(index) else { continue };
|
||||
let mut look = source_look(&line.base_font, line.size, line.color, line.bold, line.italic);
|
||||
look.size = line.size;
|
||||
let base = phrase.unwrap_or(line.size);
|
||||
let mut look = source_look(&line.base_font, base, line.color, line.bold, line.italic);
|
||||
look.size = base;
|
||||
if ed.look.family != ed.look0.family || ed.family_chosen {
|
||||
look.family = ed.look.family;
|
||||
}
|
||||
@@ -153,7 +159,7 @@ pub(super) fn editor_job(ui: &egui::Ui, ed: &LineEditor, text: &str, scale: f32,
|
||||
font.family = FontFamily::Name(name.clone().into());
|
||||
}
|
||||
let native_width = ui.fonts_mut(|f| f.layout_no_wrap(line.text.clone(), font.clone(), Color32::BLACK).size().x);
|
||||
let target_width = ((line.rect[2] - line.rect[0]) * look.size / line.size.max(0.01)) as f32 * scale;
|
||||
let target_width = ((line.rect[2] - line.rect[0]) * look.size / base.max(0.01)) as f32 * scale;
|
||||
let spacing = if original_face { (target_width - native_width) / line.text.chars().count().saturating_sub(1).max(1) as f32 } else { 0.0 };
|
||||
job.append(
|
||||
text.get(range).unwrap_or_default(),
|
||||
|
||||
@@ -8,8 +8,12 @@ pub(super) mod create_ui;
|
||||
pub(super) mod export_ui;
|
||||
pub(super) mod files;
|
||||
pub(super) mod js_ui;
|
||||
#[cfg(any(target_arch = "wasm32", test))]
|
||||
pub(super) mod ocr_route;
|
||||
pub(super) mod ocr_ui;
|
||||
pub(super) mod ocr_verify;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(super) mod ocr_web;
|
||||
pub(super) mod optimize_ui;
|
||||
pub(super) mod print_ui;
|
||||
pub(super) mod scan_clean_ui;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Which run a page read by a Web Worker belongs to (the web build's OCR, `ocr_web`).
|
||||
//!
|
||||
//! Recognize Text and Recognize Text in Multiple Files can run at once and share the workers, so
|
||||
//! each answer goes back to the run that sent its page: [`Router::send`] notes the run's
|
||||
//! [`Inbox`] under the page's id and [`Router::deliver`] puts the answer there. A run that has
|
||||
//! been dropped (cancelled or finished) gets nothing, and its answers are let go.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::{Rc, Weak};
|
||||
|
||||
/// Answers by page id.
|
||||
type Answers<T> = RefCell<Vec<(u64, T)>>;
|
||||
|
||||
/// Where one run's answers arrive.
|
||||
pub struct Inbox<T>(Rc<Answers<T>>);
|
||||
|
||||
impl<T> Default for Inbox<T> {
|
||||
fn default() -> Self {
|
||||
Inbox(Rc::new(RefCell::new(Vec::new())))
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Inbox<T> {
|
||||
/// The answers that have arrived since the last call.
|
||||
pub fn take(&self) -> Vec<(u64, T)> {
|
||||
self.0.try_borrow_mut().map(|mut v| std::mem::take(&mut *v)).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Page ids and the run each one belongs to.
|
||||
pub struct Router<T> {
|
||||
next_id: u64,
|
||||
owners: HashMap<u64, Weak<Answers<T>>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Router<T> {
|
||||
fn default() -> Self {
|
||||
Router { next_id: 0, owners: HashMap::new() }
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Router<T> {
|
||||
/// Send a page for `inbox`'s run: `post(id)` hands it to a worker under a fresh id (ids are
|
||||
/// never reused, so a late answer can't be mistaken for a newer page). The id, or `None` when
|
||||
/// `post` couldn't send it.
|
||||
pub fn send(&mut self, inbox: &Inbox<T>, post: impl FnOnce(u64) -> bool) -> Option<u64> {
|
||||
let id = self.next_id;
|
||||
if !post(id) {
|
||||
return None;
|
||||
}
|
||||
self.next_id = self.next_id.wrapping_add(1);
|
||||
self.owners.insert(id, Rc::downgrade(&inbox.0));
|
||||
Some(id)
|
||||
}
|
||||
|
||||
/// The answer for page `id`: to its run's inbox, or dropped when that run is gone or the id
|
||||
/// was never sent.
|
||||
pub fn deliver(&mut self, id: u64, answer: T) {
|
||||
let Some(inbox) = self.owners.remove(&id).and_then(|w| w.upgrade()) else { return };
|
||||
if let Ok(mut v) = inbox.try_borrow_mut() {
|
||||
v.push((id, answer));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Single-file and multiple-file OCR share the workers: whichever run started first, and
|
||||
/// whichever run collects first, each gets exactly its own pages back.
|
||||
#[test]
|
||||
fn answers_go_to_the_run_that_sent_the_page_in_either_start_order() {
|
||||
for single_first in [true, false] {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let (single, batch) = (Inbox::default(), Inbox::default());
|
||||
let (s, b) = if single_first {
|
||||
let s = r.send(&single, |_| true).expect("sent");
|
||||
(s, r.send(&batch, |_| true).expect("sent"))
|
||||
} else {
|
||||
let b = r.send(&batch, |_| true).expect("sent");
|
||||
(r.send(&single, |_| true).expect("sent"), b)
|
||||
};
|
||||
// Both answers arrive in one drain of the workers' events, the batch's first.
|
||||
r.deliver(b, "batch page");
|
||||
r.deliver(s, "single page");
|
||||
// The single run collects first, as pump_web_ocr does; it must not swallow the
|
||||
// batch's answer.
|
||||
assert_eq!(single.take(), vec![(s, "single page")], "single_first: {single_first}");
|
||||
assert_eq!(batch.take(), vec![(b, "batch page")], "single_first: {single_first}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A cancelled (dropped) run's late answers are let go, the other run still gets its own,
|
||||
/// and a run started afterwards gets its pages.
|
||||
#[test]
|
||||
fn a_cancelled_run_lets_the_other_and_the_next_run_finish() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let (single, batch) = (Inbox::default(), Inbox::default());
|
||||
let s = r.send(&single, |_| true).expect("sent");
|
||||
let b = r.send(&batch, |_| true).expect("sent");
|
||||
drop(single);
|
||||
r.deliver(s, "late");
|
||||
r.deliver(b, "batch page");
|
||||
assert_eq!(batch.take(), vec![(b, "batch page")]);
|
||||
assert!(r.owners.is_empty(), "nothing is kept for a run that's gone");
|
||||
|
||||
let next = Inbox::default();
|
||||
let n = r.send(&next, |_| true).expect("sent");
|
||||
assert_ne!(n, s, "ids are not reused");
|
||||
r.deliver(n, "next page");
|
||||
assert_eq!(next.take(), vec![(n, "next page")]);
|
||||
}
|
||||
|
||||
/// A page that couldn't be posted takes no id and has no owner.
|
||||
#[test]
|
||||
fn a_page_that_could_not_be_sent_is_not_routed() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let inbox = Inbox::default();
|
||||
assert_eq!(r.send(&inbox, |_| false), None);
|
||||
assert!(r.owners.is_empty());
|
||||
let id = r.send(&inbox, |_| true);
|
||||
assert_eq!(id, Some(0), "the id was not used up");
|
||||
}
|
||||
|
||||
/// Answers for ids nobody sent, or sent twice, change nothing.
|
||||
#[test]
|
||||
fn unknown_and_repeated_answers_are_ignored() {
|
||||
let mut r: Router<&str> = Router::default();
|
||||
let inbox = Inbox::default();
|
||||
let id = r.send(&inbox, |_| true).expect("sent");
|
||||
r.deliver(id + 100, "stray");
|
||||
r.deliver(id, "page");
|
||||
r.deliver(id, "again");
|
||||
assert_eq!(inbox.take(), vec![(id, "page")]);
|
||||
assert!(inbox.take().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,10 @@ struct ScanToRead {
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
/// Files to recognize, by name, and the settings they were chosen with.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub type WaitingFiles = (Vec<(String, Vec<u8>)>, OcrSettings);
|
||||
|
||||
pub struct OcrRun {
|
||||
pub doc: DocId,
|
||||
pub progress: Arc<Mutex<OcrProgress>>,
|
||||
@@ -206,6 +210,9 @@ pub struct OcrRun {
|
||||
/// The dialog promised an editable document: the result goes to the OCR Verify screen
|
||||
/// (whose Accept produces the editable document), not to an invisible layer.
|
||||
pub editable: bool,
|
||||
/// The web build reads the pages in Web Workers, pumped by [`PdfCraftApp::poll_ocr`].
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub web: Option<crate::ocr_web::Pages>,
|
||||
}
|
||||
|
||||
/// What an Edit-started run needs to know to refuse a result that no longer fits its document.
|
||||
@@ -351,7 +358,36 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo
|
||||
});
|
||||
});
|
||||
keep_asking_while_probing(ui.ctx());
|
||||
if let Some(why) = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine) {
|
||||
// The web build has no model files: it downloads the models the first time they're needed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let downloading = d.engine != EngineChoice::Tesseract && pdfcraft_engine::ocr::engine_unavailable_reason(EngineChoice::Ocrs).is_some();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let downloading = false;
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if downloading {
|
||||
match crate::ocr_web::state() {
|
||||
crate::ocr_web::State::Failed(e) => {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let msg = crate::i18n::fmt(tl!("Couldn't download the text recognition models: {e}"), &[("e", &e)]);
|
||||
ui.label(egui::RichText::new(msg).small().color(t.text_muted));
|
||||
if widgets::pill_button(ui, tl!("Download"), false).clicked() {
|
||||
crate::ocr_web::start(ui.ctx().clone());
|
||||
}
|
||||
});
|
||||
}
|
||||
state => {
|
||||
if state == crate::ocr_web::State::NotStarted {
|
||||
crate::ocr_web::start(ui.ctx().clone());
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label(egui::RichText::new(tl!("Downloading the text recognition models…")).small().color(t.text_muted));
|
||||
});
|
||||
}
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
if let Some(why) = pdfcraft_engine::ocr::engine_unavailable_reason(d.engine).filter(|_| !downloading) {
|
||||
// Said in the interface's language: the built-in engine's missing models get the
|
||||
// notice users know; Tesseract's reasons are catalog keys of their own.
|
||||
let text = match d.engine {
|
||||
@@ -390,6 +426,10 @@ impl PdfCraftApp {
|
||||
self.notify(why);
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.web_models_missing(self.ocr_draft.engine) {
|
||||
return;
|
||||
}
|
||||
let d = &self.ocr_draft;
|
||||
let pages: Vec<usize> = match d.pages {
|
||||
OcrPages::All => Vec::new(),
|
||||
@@ -408,12 +448,13 @@ impl PdfCraftApp {
|
||||
/// boxes over it do not count as text, but real page text does, and such a page (a flyer with a
|
||||
/// background picture) is left alone, and is judged again only when the document changes. It
|
||||
/// is one undoable step, "Recognize text", and is not run again for that page after Undo.
|
||||
/// Without an engine the page waits for one (see [`Self::await_ocr_models`]).
|
||||
pub(crate) fn auto_ocr_scanned_page(&mut self) {
|
||||
let Some(scan) = self.scan_to_read() else { return };
|
||||
// the engine this run will use, not always the built-in one
|
||||
let mut settings = self.ocr_draft.settings();
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(settings.engine).is_some() {
|
||||
self.views[scan.view].auto_ocr_skipped.insert(scan.page, scan.generation);
|
||||
self.await_ocr_models(&scan);
|
||||
return;
|
||||
}
|
||||
self.views[scan.view].auto_ocr_tried.insert(scan.page);
|
||||
@@ -425,6 +466,30 @@ impl PdfCraftApp {
|
||||
self.start_ocr_pages(scan.doc, &[scan.page], settings, true);
|
||||
}
|
||||
|
||||
/// Edit found a scan but no engine can read it. The page is set aside until one can (rather
|
||||
/// than judged again every frame), and the web build starts downloading the models, as
|
||||
/// Recognize Text does. After a failed download it doesn't try again by itself: Recognize
|
||||
/// Text does, and the page is read once the models are in.
|
||||
fn await_ocr_models(&mut self, scan: &ScanToRead) {
|
||||
self.views[scan.view].auto_ocr_awaiting_models.insert(scan.page, scan.generation);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if crate::ocr_web::state() == crate::ocr_web::State::NotStarted
|
||||
&& let Some(ctx) = self.ctx.clone()
|
||||
{
|
||||
crate::ocr_web::start(ctx);
|
||||
self.notify(tl!("Downloading the text recognition models…"));
|
||||
}
|
||||
}
|
||||
|
||||
/// The OCR models have just loaded: the scans Edit set aside for want of them may be read.
|
||||
/// Only the web build loads them while running.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) fn ocr_models_loaded(&mut self) {
|
||||
for view in &mut self.views {
|
||||
view.auto_ocr_awaiting_models.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The page Edit should read now, if there is one: Edit is on outside Read mode with no dialog
|
||||
/// open, nothing else is being recognized, the document may be changed, the page in view has
|
||||
/// not been read before (nor been found no scan since the document last changed), and an image
|
||||
@@ -438,7 +503,7 @@ impl PdfCraftApp {
|
||||
// Not in Read mode (which leaves the Edit tool picked but is not editing) and not beneath a
|
||||
// dialog or prompt: nothing may change the document under those. It is tried again once
|
||||
// they are gone.
|
||||
if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch.is_some() {
|
||||
if !editing || self.mode == crate::Mode::Read || self.modal_open() || self.ocr_run.is_some() || self.ocr_batch_running() {
|
||||
return None;
|
||||
}
|
||||
let (view, doc) = self.active_ids()?;
|
||||
@@ -453,7 +518,9 @@ impl PdfCraftApp {
|
||||
// A page found not to be a scan is judged again only once the document has changed, not
|
||||
// every frame (listing its images parses the page).
|
||||
let generation = document.edit_generation();
|
||||
if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation) {
|
||||
if self.views[view].auto_ocr_skipped.get(&page) == Some(&generation)
|
||||
|| self.views[view].auto_ocr_awaiting_models.get(&page) == Some(&generation)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let info = document.info.pages.get(page)?;
|
||||
@@ -481,6 +548,29 @@ impl PdfCraftApp {
|
||||
let guard = job.guard.clone();
|
||||
let editable = settings.output_mode == OutputMode::EditableText;
|
||||
let progress = Arc::new(Mutex::new(OcrProgress { total: job.pages.len(), ..Default::default() }));
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let web = Some(crate::ocr_web::Pages::new(job.steps()));
|
||||
self.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: auto, editable, web });
|
||||
self.poll_ocr();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.run_ocr_job(id, job, settings, progress, guard, auto, editable);
|
||||
}
|
||||
|
||||
/// Read the pages on a worker thread (inline in tests).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn run_ocr_job(
|
||||
&mut self,
|
||||
id: DocId,
|
||||
job: pdfcraft_engine::ocr::OcrJob,
|
||||
settings: OcrSettings,
|
||||
progress: Arc<Mutex<OcrProgress>>,
|
||||
guard: pdfcraft_engine::ocr::OcrGuard,
|
||||
auto: Option<AutoOcr>,
|
||||
editable: bool,
|
||||
) {
|
||||
let p = progress.clone();
|
||||
// A failure is written on the worker thread: in the UI's language.
|
||||
let lang = crate::i18n::current();
|
||||
@@ -500,14 +590,11 @@ impl PdfCraftApp {
|
||||
s.result = Some(result);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr".into()).spawn(work).ok();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.ocr_run = Some(OcrRun { doc: id, progress, guard, automatic: auto, editable });
|
||||
self.poll_ocr();
|
||||
}
|
||||
@@ -515,7 +602,7 @@ impl PdfCraftApp {
|
||||
/// Recognize text in each file, writing the searchable copies into a folder the user picks
|
||||
/// (the export folder override in tests), under the same names.
|
||||
pub fn ocr_files(&mut self, files: Vec<(String, Vec<u8>)>) {
|
||||
if self.ocr_batch.is_some() {
|
||||
if self.ocr_batch_running() {
|
||||
self.notify_tr("Text recognition is already running");
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +624,7 @@ impl PdfCraftApp {
|
||||
/// [`Self::ocr_files`] once the folder for the searchable files is known.
|
||||
fn ocr_files_into(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings, #[cfg(not(target_arch = "wasm32"))] dir: std::path::PathBuf) {
|
||||
// Another batch may have started while the folder picker was open.
|
||||
if self.ocr_batch.is_some() {
|
||||
if self.ocr_batch_running() {
|
||||
self.notify_tr("Text recognition is already running");
|
||||
return;
|
||||
}
|
||||
@@ -576,6 +663,67 @@ impl PdfCraftApp {
|
||||
return;
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
// The files wait for the models rather than having to be chosen again.
|
||||
if self.web_models_missing(settings.engine) {
|
||||
self.ocr_web_waiting = Some((files, settings));
|
||||
return;
|
||||
}
|
||||
self.start_web_batch(files, settings);
|
||||
self.poll_ocr();
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
self.run_ocr_files(files, settings, dir);
|
||||
}
|
||||
|
||||
/// Whether Recognize Text in Multiple Files is running (on the web, or waiting for the
|
||||
/// models to download).
|
||||
fn ocr_batch_running(&self) -> bool {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if self.ocr_web_waiting.is_some() {
|
||||
return true;
|
||||
}
|
||||
self.ocr_batch.is_some()
|
||||
}
|
||||
|
||||
/// The web build: read the files in Web Workers (advanced by [`Self::poll_ocr`]).
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn start_web_batch(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings) {
|
||||
let progress = Arc::new(Mutex::new(BatchProgress { total: files.len(), ..Default::default() }));
|
||||
self.ocr_web_batch = Some(crate::ocr_web::Batch::new(files, settings));
|
||||
self.ocr_batch = Some(progress);
|
||||
}
|
||||
|
||||
/// The web build: start the files that waited for the models once they are loaded, or give
|
||||
/// them up with the reason when the download failed.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn start_waiting_web_batch(&mut self) {
|
||||
let Some((_, settings)) = &self.ocr_web_waiting else { return };
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(settings.engine).is_none() {
|
||||
if let Some((files, settings)) = self.ocr_web_waiting.take() {
|
||||
self.start_web_batch(files, settings);
|
||||
}
|
||||
return;
|
||||
}
|
||||
match crate::ocr_web::state() {
|
||||
crate::ocr_web::State::Loading => {}
|
||||
crate::ocr_web::State::Failed(e) => {
|
||||
self.ocr_web_waiting = None;
|
||||
self.notify(failed(&e));
|
||||
}
|
||||
// Not loading and not loaded (nothing has started the download): start it.
|
||||
crate::ocr_web::State::NotStarted => {
|
||||
if let Some(ctx) = self.ctx.clone() {
|
||||
crate::ocr_web::start(ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recognize text in the files on a worker thread (inline in tests).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn run_ocr_files(&mut self, files: Vec<(String, Vec<u8>)>, settings: OcrSettings, dir: std::path::PathBuf) {
|
||||
// The engines are built (and named if they cannot be) before anything starts.
|
||||
let recognizers = match pdfcraft_engine::ocr::recognizers(&settings) {
|
||||
Ok(r) => r,
|
||||
@@ -590,12 +738,9 @@ impl PdfCraftApp {
|
||||
let lang = crate::i18n::current();
|
||||
let work = move || {
|
||||
crate::i18n::set_current(lang);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let write = move |name: &str, r: &pdfcraft_engine::ocr::FileResult| {
|
||||
crate::editing::write_atomically(&dir.join(name).to_string_lossy(), &r.bytes).map_err(|e| e.to_string())
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let write = move |name: &str, r: &pdfcraft_engine::ocr::FileResult| crate::editing::download(name, &r.bytes);
|
||||
// A panic in the recognizer still ends the batch with a summary (#3919).
|
||||
let msg = pdfcraft_engine::guard(|| run_batch_files(files, settings, &recognizers, &p, write)).unwrap_or_else(|panic| failed(&panic));
|
||||
if let Ok(mut s) = p.lock() {
|
||||
@@ -603,14 +748,11 @@ impl PdfCraftApp {
|
||||
s.message = Some(msg);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if self.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr-files".into()).spawn(work).ok();
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.ocr_batch = Some(progress);
|
||||
self.poll_ocr();
|
||||
}
|
||||
@@ -634,8 +776,74 @@ impl PdfCraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// The web build downloads the models the first time they're needed: while `engine` can't
|
||||
/// run for want of them, start (or retry) the download, say so and return `true`.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn web_models_missing(&mut self, engine: EngineChoice) -> bool {
|
||||
if pdfcraft_engine::ocr::engine_unavailable_reason(engine).is_none() {
|
||||
return false;
|
||||
}
|
||||
if crate::ocr_web::state() != crate::ocr_web::State::Loading
|
||||
&& let Some(ctx) = self.ctx.clone()
|
||||
{
|
||||
crate::ocr_web::start(ctx);
|
||||
}
|
||||
self.notify(tl!("Downloading the text recognition models…"));
|
||||
true
|
||||
}
|
||||
|
||||
/// The web build: advance the pages being read in Web Workers.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn pump_web_ocr(&mut self) {
|
||||
self.start_waiting_web_batch();
|
||||
if self.views.iter().any(|v| !v.auto_ocr_awaiting_models.is_empty())
|
||||
&& pdfcraft_engine::ocr::engine_unavailable_reason(EngineChoice::Ocrs).is_none()
|
||||
{
|
||||
self.ocr_models_loaded();
|
||||
}
|
||||
let Some(ctx) = self.ctx.clone() else { return };
|
||||
let mut busy = false;
|
||||
if let Some(run) = self.ocr_run.as_mut()
|
||||
&& let Some(pages) = run.web.as_mut()
|
||||
{
|
||||
busy = true;
|
||||
let cancel = run.progress.lock().map_or(true, |s| s.cancel);
|
||||
let found = pages.pump(cancel, &ctx);
|
||||
let finished = found.is_some();
|
||||
if let Ok(mut s) = run.progress.lock() {
|
||||
(s.done, s.total) = (pages.done(), pages.total());
|
||||
if let Some(found) = found {
|
||||
s.result = Some(Ok(found));
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
run.web = None;
|
||||
}
|
||||
}
|
||||
if let Some(batch) = self.ocr_web_batch.as_mut() {
|
||||
busy = true;
|
||||
let progress = self.ocr_batch.clone();
|
||||
let cancel = progress.as_ref().is_some_and(|b| b.lock().map_or(true, |s| s.cancel));
|
||||
let finished = batch.pump(cancel, &ctx);
|
||||
if let Some(Ok(mut s)) = progress.as_ref().map(|b| b.lock()) {
|
||||
s.done = batch.done();
|
||||
if finished {
|
||||
s.message = Some(batch_summary(batch.ok, batch.words, batch.skipped, &batch.failed, &batch.notes, batch.not_run));
|
||||
}
|
||||
}
|
||||
if finished {
|
||||
self.ocr_web_batch = None;
|
||||
}
|
||||
}
|
||||
if !busy {
|
||||
crate::ocr_web::close_idle_workers(&ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Show progress; apply the result once the worker is done.
|
||||
pub(crate) fn poll_ocr(&mut self) {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
self.pump_web_ocr();
|
||||
if let Some(b) = self.ocr_batch.clone() {
|
||||
let msg = b.lock().map(|mut s| s.message.take().ok_or((s.done, s.total))).map_err(|e| e.to_string());
|
||||
match msg {
|
||||
@@ -774,6 +982,7 @@ impl PdfCraftApp {
|
||||
/// or the web's download), count the buckets, and compose the summary. A file the skip-files
|
||||
/// option passes over is its own bucket and `write` is never called for it. A cancel is asked
|
||||
/// for between files: the file in flight finishes, the rest are counted as not run.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) fn run_batch_files(
|
||||
files: Vec<(String, Vec<u8>)>,
|
||||
settings: OcrSettings,
|
||||
@@ -799,14 +1008,8 @@ pub(crate) fn run_batch_files(
|
||||
Ok(()) => {
|
||||
ok += 1;
|
||||
words += res.words();
|
||||
// The batch's low-confidence note: at or under the settings' threshold (60 by
|
||||
// default), on 1-based sorted pages.
|
||||
if let Some((count, pages)) = pdfcraft_engine::ocr::low_confidence_pages(&res.pages, settings.low_confidence_threshold) {
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
notes.push(crate::i18n::fmt(
|
||||
tl!("{name}: {n} low-confidence word(s) on pages {pages}"),
|
||||
&[("name", &name), ("n", &count.to_string()), ("pages", &list)],
|
||||
));
|
||||
if let Some(note) = low_confidence_note(&name, &res, settings.low_confidence_threshold) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
Err(e) => failed.push(format!("{name}: {e}")),
|
||||
@@ -817,6 +1020,14 @@ pub(crate) fn run_batch_files(
|
||||
batch_summary(ok, words, skipped, &failed, ¬es, not_run)
|
||||
}
|
||||
|
||||
/// The batch's low-confidence note for one file: its words at or under `threshold` (60 by
|
||||
/// default), on 1-based sorted pages; `None` when it has none.
|
||||
pub(crate) fn low_confidence_note(name: &str, res: &pdfcraft_engine::ocr::FileResult, threshold: f32) -> Option<String> {
|
||||
let (count, pages) = pdfcraft_engine::ocr::low_confidence_pages(&res.pages, threshold)?;
|
||||
let list = pages.iter().map(|p| p.to_string()).collect::<Vec<_>>().join(", ");
|
||||
Some(crate::i18n::fmt(tl!("{name}: {n} low-confidence word(s) on pages {pages}"), &[("name", name), ("n", &count.to_string()), ("pages", &list)]))
|
||||
}
|
||||
|
||||
/// The batch summary: what was read, the skipped files as their own bucket, the failures, the
|
||||
/// low-confidence notes and (on a cancel) how many files were not run.
|
||||
fn batch_summary(ok: usize, words: usize, skipped: usize, failed: &[String], notes: &[String], not_run: Option<usize>) -> String {
|
||||
@@ -848,6 +1059,7 @@ fn failed(e: &str) -> String {
|
||||
|
||||
/// `recognize`, with a panic in it (a damaged model, a page the recognizer can't take) turned into
|
||||
/// the run's error: the run has to end either way, or it refuses every later one (#3919).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn recognize_guarded<T>(recognize: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
|
||||
pdfcraft_engine::guard(recognize).unwrap_or_else(|panic| Err(failed(&panic)))
|
||||
}
|
||||
@@ -935,6 +1147,18 @@ mod tests {
|
||||
app.session.apply(id, add).unwrap();
|
||||
assert_eq!(found(&mut app), Some(0), "a picture over the page makes it a scan");
|
||||
}
|
||||
/// A scan found while no engine could read it waits for one, and is read once the models
|
||||
/// load, though the document has not changed. Before, it was taken for no scan and left unread.
|
||||
#[test]
|
||||
fn a_scan_found_without_the_models_is_read_once_they_load() {
|
||||
let mut app = app_with(scan());
|
||||
let scan = app.scan_to_read().unwrap();
|
||||
app.await_ocr_models(&scan);
|
||||
assert_eq!(found(&mut app), None, "it waits for the models, not judged every frame");
|
||||
app.ocr_models_loaded();
|
||||
assert_eq!(found(&mut app), Some(0), "read once they are in");
|
||||
assert!(app.views[0].auto_ocr_skipped.is_empty(), "never taken for no scan");
|
||||
}
|
||||
/// A finished run Edit started is applied only once Read mode and dialogs are gone; one the user
|
||||
/// asked for is applied at once. The result is made up, so no OCR models are needed.
|
||||
#[test]
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
//! Scan & OCR ▸ Correct recognized text: the OCR Verify screen. A full-workspace surface with
|
||||
//! four panes (page list, source page, recognized text, zoom + word inspector) where a
|
||||
//! recognition is reviewed before it is applied: words are colored by confidence, corrected,
|
||||
//! removed (and restored), sent to a per-language user dictionary, and navigated by their
|
||||
//! uncertainty. Accept applies the review — as one undoable step on the open document
|
||||
//! (searchable), or as a new `<name>_ocr.pdf` of visible text (editable). Reject throws the
|
||||
//! reading away.
|
||||
//! Scan & OCR ▸ Correct recognized text: the OCR Verify screen. It takes the document area,
|
||||
//! under the window's tabs, menus and toolbar, with four panes (page list, source page,
|
||||
//! recognized text, zoom + word inspector) where a recognition is reviewed before it is
|
||||
//! applied: words are colored by confidence, corrected, removed (and restored), sent to a
|
||||
//! per-language user dictionary, and navigated by their uncertainty. Accept applies the
|
||||
//! review — as one undoable step on the open document (searchable), or as a new
|
||||
//! `<name>_ocr.pdf` of visible text (editable). Reject throws the reading away.
|
||||
//!
|
||||
//! The state machine is [`Phase`]: recognition runs on a worker thread ([`Phase::Running`], a
|
||||
//! real Cancel keeps the pages already read), results land in [`Phase::ReviewReady`], failures
|
||||
//! land in [`Phase::RecoverableError`] with the cause named and Run enabled again, and
|
||||
//! Accept/Reject/Re-OCR are ignored outside ReviewReady instead of acting on nothing.
|
||||
//! Accept/Reject/Re-OCR are ignored outside ReviewReady instead of acting on nothing (Reject
|
||||
//! also throws away a reading whose Accept failed).
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
@@ -25,7 +26,8 @@ use crate::theme::{self, Tokens};
|
||||
use crate::{PdfCraftApp, widgets};
|
||||
|
||||
/// Where the screen stands. Run is enabled in Idle, ReviewReady and RecoverableError; the
|
||||
/// review tools (Accept, Reject, navigation, the inspector, re-OCR) only in ReviewReady.
|
||||
/// review tools (Accept, Reject, navigation, the inspector, re-OCR) only in ReviewReady, and
|
||||
/// Reject also in RecoverableError while a reading is kept.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum Phase {
|
||||
#[default]
|
||||
@@ -199,6 +201,15 @@ pub struct Preview {
|
||||
pub(crate) texture: Option<egui::TextureHandle>,
|
||||
}
|
||||
|
||||
/// A page whose preview render failed. It is tried again once the document's bytes change (an
|
||||
/// edit, a revert) or after another page rendered. `bytes` is the rendered copy, held weakly so
|
||||
/// it is not kept alive: while it is held, no newer copy can take its address.
|
||||
struct FailedPreview {
|
||||
doc: DocId,
|
||||
page: usize,
|
||||
bytes: std::sync::Weak<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// A page or region re-read in flight ([`OcrVerify::start_reread`]): the page being read
|
||||
/// again (and the drawn region, in view-space points, for a region re-read), with the
|
||||
/// worker's progress.
|
||||
@@ -208,6 +219,15 @@ pub struct Reread {
|
||||
pub(crate) progress: Arc<Mutex<RereadProgress>>,
|
||||
}
|
||||
|
||||
/// What the Verify screen was asked to read while the web build was still downloading the
|
||||
/// models ([`OcrVerify::models_landed`] starts it once they are in).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) enum AwaitingModels {
|
||||
Run,
|
||||
Reread { page: usize, region: Option<[f32; 4]> },
|
||||
}
|
||||
|
||||
/// A re-read's progress: the replacement words once the worker is done, and a cancel request.
|
||||
/// A cancelled re-read is discarded when it lands: the page's words stay as they were.
|
||||
#[derive(Default)]
|
||||
@@ -220,7 +240,8 @@ pub struct RereadProgress {
|
||||
pub struct OcrVerify {
|
||||
pub open: bool,
|
||||
pub phase: Phase,
|
||||
/// The document being reviewed; `None` until a run starts.
|
||||
/// The document being reviewed; before a run, the document in front (the source pane shows
|
||||
/// its page).
|
||||
pub doc: Option<DocId>,
|
||||
/// The recognition results, in page order, as read (review edits mutate them).
|
||||
pub pages: Vec<OcrPage>,
|
||||
@@ -243,6 +264,8 @@ pub struct OcrVerify {
|
||||
/// A page or region re-read running on a worker thread: the screen stays live (the review
|
||||
/// edits and navigation work) and the reading is applied by [`Self::poll_reread`].
|
||||
pub reread: Option<Reread>,
|
||||
/// A run or re-read waiting for the web build's model download.
|
||||
pub(crate) awaiting_models: Option<AwaitingModels>,
|
||||
/// The correction text field's current contents.
|
||||
pub correction: String,
|
||||
/// Where the per-language dictionary files live (`None`: off, e.g. the web build).
|
||||
@@ -250,6 +273,11 @@ pub struct OcrVerify {
|
||||
/// Language of the captured recognition, independent of the interface locale.
|
||||
pub dictionary_language: String,
|
||||
pub(crate) preview: Option<Preview>,
|
||||
/// The last page whose preview could not be rendered: not rendered again on every frame.
|
||||
preview_failed: Option<FailedPreview>,
|
||||
/// The mode the screen was last drawn in (`None` while it is closed): switching to another
|
||||
/// puts the screen away.
|
||||
pub(crate) shown_in: Option<crate::state::Mode>,
|
||||
}
|
||||
|
||||
fn dictionary_path(dir: &std::path::Path, language: &str) -> Result<PathBuf, String> {
|
||||
@@ -290,10 +318,13 @@ impl Default for OcrVerify {
|
||||
cancelled: false,
|
||||
progress: None,
|
||||
reread: None,
|
||||
awaiting_models: None,
|
||||
correction: String::new(),
|
||||
dictionary_dir: None,
|
||||
dictionary_language: "en".into(),
|
||||
preview: None,
|
||||
preview_failed: None,
|
||||
shown_in: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -308,11 +339,53 @@ impl OcrVerify {
|
||||
self.review = Review::default();
|
||||
self.selected = None;
|
||||
self.region = None;
|
||||
self.current = self.current.min(self.pages.len().saturating_sub(1));
|
||||
self.show_a_read_page();
|
||||
self.phase = Phase::ReviewReady;
|
||||
self.load_dictionary_pref();
|
||||
}
|
||||
|
||||
/// Show a page the reading has words for: the current page if it was read, else the first
|
||||
/// page read. When every page was skipped, the current page if the reading has it, else its
|
||||
/// first page; a reading with no pages (a run cancelled before its first page) keeps the
|
||||
/// page the run was started on.
|
||||
fn show_a_read_page(&mut self) {
|
||||
if self.page(self.current).is_some_and(|p| p.skipped.is_none()) {
|
||||
return;
|
||||
}
|
||||
if let Some(p) = self.pages.iter().find(|p| p.skipped.is_none()) {
|
||||
self.current = p.page;
|
||||
} else if self.page(self.current).is_none()
|
||||
&& let Some(p) = self.pages.first()
|
||||
{
|
||||
self.current = p.page;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the screen holds a run or a reading not yet accepted or rejected (a failed run
|
||||
/// that read nothing holds none).
|
||||
fn has_reading(&self) -> bool {
|
||||
!(self.phase == Phase::Idle || (self.phase == Phase::RecoverableError && self.pages.is_empty()))
|
||||
}
|
||||
|
||||
/// Whether the screen holds a run or a reading of `doc`: closing that document would leave
|
||||
/// the review with nothing to apply to.
|
||||
pub(crate) fn holds_reading_of(&self, doc: DocId) -> bool {
|
||||
self.doc == Some(doc) && self.has_reading()
|
||||
}
|
||||
|
||||
/// The info strip's PAGE cell: the shown page's number in the document over the document's
|
||||
/// page count (`page_count`), the numbering the page list uses; "—" before a reading.
|
||||
fn page_indicator(&self, page_count: Option<usize>) -> String {
|
||||
if self.pages.is_empty() {
|
||||
return "—".into();
|
||||
}
|
||||
let shown = self.current.saturating_add(1);
|
||||
match page_count {
|
||||
Some(total) if total > 0 => format!("{shown} / {total}"),
|
||||
_ => shown.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The count of live (not removed, not empty) words across the review.
|
||||
pub fn word_count(&self) -> usize {
|
||||
self.pages
|
||||
@@ -395,6 +468,13 @@ impl OcrVerify {
|
||||
self.phase == Phase::ReviewReady
|
||||
}
|
||||
|
||||
/// Whether Reject has a reading to throw away: in review, and after a failed Accept, which
|
||||
/// keeps the reading (a stale document, say). Its document stays open until the reading is
|
||||
/// accepted or rejected, so Reject must not wait for a new run that would replace it.
|
||||
pub fn can_reject(&self) -> bool {
|
||||
self.in_review() || (self.phase == Phase::RecoverableError && !self.pages.is_empty())
|
||||
}
|
||||
|
||||
/// Commit the correction field for `key`. An empty (after trim) commit removes the word;
|
||||
/// the box never moves. Only in ReviewReady.
|
||||
pub fn correct(&mut self, key: (usize, usize), text: &str) -> bool {
|
||||
@@ -809,9 +889,9 @@ impl OcrVerify {
|
||||
}
|
||||
|
||||
/// Reject: clear the words, the session, the selection and the flags; back to Idle.
|
||||
/// Ignored outside ReviewReady.
|
||||
/// Ignored outside ReviewReady, except after a failed Accept ([`Self::can_reject`]).
|
||||
pub fn reject(&mut self) -> bool {
|
||||
if !self.in_review() {
|
||||
if !self.can_reject() {
|
||||
return false;
|
||||
}
|
||||
*self = OcrVerify { open: self.open, dictionary_dir: self.dictionary_dir.take(), ..Default::default() };
|
||||
@@ -851,6 +931,11 @@ impl OcrVerify {
|
||||
}
|
||||
let Some(doc) = self.doc else { return };
|
||||
let settings = self.reread_settings(&app.ocr_draft);
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if app.web_models_missing(settings.engine) {
|
||||
self.wait_for_models(AwaitingModels::Reread { page, region });
|
||||
return;
|
||||
}
|
||||
if let Some(region) = region {
|
||||
let Some(info) = app.session.get(doc).and_then(|d| d.info.pages.get(page)) else {
|
||||
app.notify_error("the page is gone");
|
||||
@@ -896,17 +981,60 @@ impl OcrVerify {
|
||||
s.result = Some(result);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr-reread".into()).spawn(work).ok();
|
||||
// The OS can refuse a new thread: then nothing runs, and the page's words stay as they were.
|
||||
if let Err(e) = launch(app, "pdfcraft-ocr-reread", work) {
|
||||
self.message = format!("the re-read could not start: {e}");
|
||||
app.notify_error(self.message.clone());
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.reread = Some(Reread { page, region, progress });
|
||||
}
|
||||
|
||||
/// The web build is downloading the models: remember what was asked for and say so; it
|
||||
/// starts when they land ([`Self::poll_models`]). Before, the read ran at once and failed
|
||||
/// with the desktop's "set PDFCRAFT_MODELS" message, which means nothing in a browser.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn wait_for_models(&mut self, what: AwaitingModels) {
|
||||
self.awaiting_models = Some(what);
|
||||
self.message = tl!("Downloading the text recognition models…").to_string();
|
||||
}
|
||||
|
||||
/// The web build: start what waited for the models once the download is over.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub(crate) fn poll_models(&mut self, app: &mut PdfCraftApp) {
|
||||
if self.awaiting_models.is_none() {
|
||||
return;
|
||||
}
|
||||
use pdfcraft_engine::ocr::{EngineChoice, engine_unavailable_reason};
|
||||
if engine_unavailable_reason(EngineChoice::Ocrs).is_none() {
|
||||
// The models are in. An engine the browser can never run (Tesseract) says why
|
||||
// rather than waiting for ever.
|
||||
let outcome = engine_unavailable_reason(app.ocr_draft.engine).map_or(Ok(()), Err);
|
||||
self.models_landed(app, outcome);
|
||||
} else if let crate::ocr_web::State::Failed(e) = crate::ocr_web::state() {
|
||||
self.models_landed(app, Err(e));
|
||||
}
|
||||
}
|
||||
|
||||
/// The model download is over: run what waited for it, or say why it can't. Nothing runs
|
||||
/// once the screen is closed.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
pub(crate) fn models_landed(&mut self, app: &mut PdfCraftApp, outcome: Result<(), String>) {
|
||||
let Some(what) = self.awaiting_models.take() else { return };
|
||||
self.message.clear();
|
||||
if let Err(e) = outcome {
|
||||
self.message = crate::i18n::fmt(tl!("Couldn't download the text recognition models: {e}"), &[("e", &e)]);
|
||||
return;
|
||||
}
|
||||
if !self.open {
|
||||
return;
|
||||
}
|
||||
match what {
|
||||
AwaitingModels::Run => self.start_run(app),
|
||||
AwaitingModels::Reread { page, region } => self.start_reread(app, page, region),
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask a running re-read to stop and be discarded: when the worker lands, its reading is
|
||||
/// thrown away and the page's words stay as they were.
|
||||
pub fn cancel_reread(&mut self) {
|
||||
@@ -1040,7 +1168,7 @@ impl OcrVerify {
|
||||
self.review = Review::default();
|
||||
self.selected = None;
|
||||
self.region = None;
|
||||
self.current = self.current.min(self.pages.len().saturating_sub(1));
|
||||
self.show_a_read_page();
|
||||
let total = self.pages.len();
|
||||
self.message = if cancelled {
|
||||
crate::i18n::fmt(
|
||||
@@ -1074,6 +1202,12 @@ impl OcrVerify {
|
||||
if self.reread.is_some() {
|
||||
return; // the page re-read in flight replaces these words; one worker at a time
|
||||
}
|
||||
// A review of another document stays until it is accepted or rejected: a run here would
|
||||
// replace its corrections.
|
||||
if self.has_reading() && self.doc != Some(id) {
|
||||
self.message = tl!("Accept or reject the OCR review of the other document before running OCR here").to_string();
|
||||
return;
|
||||
}
|
||||
if let Some(why) = app.session.get(id).and_then(|d| d.read_only_reason.clone()) {
|
||||
self.message = why;
|
||||
self.phase = Phase::RecoverableError;
|
||||
@@ -1086,15 +1220,17 @@ impl OcrVerify {
|
||||
super::ocr_ui::OcrPages::Range => (d.from.saturating_sub(1)..d.to).collect(),
|
||||
};
|
||||
let settings = d.settings();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
if app.web_models_missing(settings.engine) {
|
||||
self.wait_for_models(AwaitingModels::Run);
|
||||
return;
|
||||
}
|
||||
let Some(job) = app.session.ocr_job(id, &pages, settings.clone()) else { return };
|
||||
self.dictionary_language = job.settings.language.clone();
|
||||
let language = job.settings.language.clone();
|
||||
let guard = job.guard.clone();
|
||||
// The source pane shows the first page this run reads (the current page, for that scope).
|
||||
let first = job.pages.first().copied().unwrap_or(0);
|
||||
let progress = Arc::new(Mutex::new(ocr_ui::OcrProgress { total: job.pages.len(), ..Default::default() }));
|
||||
self.doc = Some(id);
|
||||
self.guard = Some(job.guard.clone());
|
||||
self.current = 0;
|
||||
self.cancelled = false;
|
||||
self.phase = Phase::Running;
|
||||
self.message = String::new();
|
||||
let p = progress.clone();
|
||||
let work = move || {
|
||||
let result = pdfcraft_engine::ocr::recognizers(&settings).map(|r| {
|
||||
@@ -1109,14 +1245,24 @@ impl OcrVerify {
|
||||
s.result = Some(result);
|
||||
}
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if app.run_inline {
|
||||
work();
|
||||
} else {
|
||||
std::thread::Builder::new().name("pdfcraft-ocr-verify".into()).spawn(work).ok();
|
||||
// The OS can refuse a new thread. Then nothing runs: a review already on the screen stays
|
||||
// as it was, and an empty screen shows why (Run stays enabled, and nothing holds the
|
||||
// document open).
|
||||
if let Err(e) = launch(app, "pdfcraft-ocr-verify", work) {
|
||||
self.message = format!("the recognition could not start: {e}");
|
||||
if self.phase == Phase::Idle {
|
||||
self.phase = Phase::RecoverableError;
|
||||
}
|
||||
app.notify_error(self.message.clone());
|
||||
return;
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
work();
|
||||
self.dictionary_language = language;
|
||||
self.doc = Some(id);
|
||||
self.guard = Some(guard);
|
||||
self.current = first;
|
||||
self.cancelled = false;
|
||||
self.phase = Phase::Running;
|
||||
self.message = String::new();
|
||||
self.progress = Some(progress);
|
||||
self.poll(app);
|
||||
}
|
||||
@@ -1149,6 +1295,23 @@ impl OcrVerify {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `work` on a worker thread named `name` (inline under `run_inline` and on the web).
|
||||
/// Whether it started: the OS can refuse a new thread.
|
||||
fn launch(app: &PdfCraftApp, name: &str, work: impl FnOnce() + Send + 'static) -> std::io::Result<()> {
|
||||
#[cfg(test)]
|
||||
if tests::FAIL_LAUNCH.with(std::cell::Cell::get) {
|
||||
return Err(std::io::Error::other("no thread for this test"));
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if !app.run_inline {
|
||||
return std::thread::Builder::new().name(name.to_string()).spawn(work).map(drop);
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let _ = (app, name);
|
||||
work();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a frame's controls asked for, applied after the drawing (the panes read `app` while
|
||||
/// the controls write it, so nothing mutates through an open borrow).
|
||||
#[derive(Default)]
|
||||
@@ -1186,9 +1349,10 @@ fn band_color(band: ConfidenceBand) -> egui::Color32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole-window OCR Verify screen (Scan & OCR ▸ Correct recognized text).
|
||||
/// The OCR Verify screen (Scan & OCR ▸ Correct recognized text), in the document area.
|
||||
pub(crate) fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
use egui::{Key, Modifiers};
|
||||
follow_active_document(app);
|
||||
let ctx = ui.ctx().clone();
|
||||
let t = Tokens::get(&ctx);
|
||||
let wants_text = ctx.egui_wants_keyboard_input();
|
||||
@@ -1253,6 +1417,18 @@ pub(crate) fn show(app: &mut PdfCraftApp, ui: &mut egui::Ui) {
|
||||
apply(app, n);
|
||||
}
|
||||
|
||||
/// With no reading in hand (before a run, or after a review was accepted or rejected), the
|
||||
/// source pane shows the document in front at the page it is on, so the screen never opens on
|
||||
/// a blank pane (#6485). A run or a review keeps the document and page it was made for.
|
||||
fn follow_active_document(app: &mut PdfCraftApp) {
|
||||
if app.ocr_verify.has_reading() {
|
||||
return;
|
||||
}
|
||||
let active = app.active_ids();
|
||||
app.ocr_verify.doc = active.map(|(_, id)| id);
|
||||
app.ocr_verify.current = active.and_then(|(i, _)| app.views.get(i)).map_or(0, |view| view.current);
|
||||
}
|
||||
|
||||
/// A pane's caption; the focused pane's name is underlined (Ctrl+Tab moves the focus).
|
||||
fn pane_title(ui: &mut egui::Ui, t: &Tokens, name: &str, focused: bool) {
|
||||
ui.add_space(2.0);
|
||||
@@ -1320,7 +1496,7 @@ fn toolbar(app: &mut PdfCraftApp, ui: &mut egui::Ui, n: &mut Intents) {
|
||||
if ui.add_enabled_ui(accept_enabled, |ui| widgets::pill_button(ui, tl!("Accept"), false)).inner.clicked() {
|
||||
n.accept = true;
|
||||
}
|
||||
if ui.add_enabled_ui(review, |ui| widgets::pill_button(ui, tl!("Reject"), false)).inner.clicked() {
|
||||
if ui.add_enabled_ui(v.can_reject(), |ui| widgets::pill_button(ui, tl!("Reject"), false)).inner.clicked() {
|
||||
n.reject = true;
|
||||
}
|
||||
ui.separator();
|
||||
@@ -1459,7 +1635,7 @@ fn info_strip(app: &PdfCraftApp, ui: &mut egui::Ui, t: &Tokens) {
|
||||
let read = v.pages.iter().filter(|p| p.skipped.is_none()).count();
|
||||
let avg = v.page(v.current).and_then(pdfcraft_engine::ocr::OcrPage::mean_confidence);
|
||||
let cells = [
|
||||
(tl!("PAGE"), if v.pages.is_empty() { "—".into() } else { format!("{} / {}", v.current + 1, v.pages.len()) }),
|
||||
(tl!("PAGE"), v.page_indicator(v.doc.and_then(|d| app.session.get(d)).map(|d| d.info.pages.len()))),
|
||||
(tl!("AVG CONFIDENCE"), avg.map_or_else(|| "—".into(), |c| format!("{c:.0}%"))),
|
||||
(tl!("LOW-CONFIDENCE WORDS"), v.suspect_count().to_string()),
|
||||
(tl!("VERIFIED %"), format!("{}%", (v.verified_fraction() * 100.0).round() as i64)),
|
||||
@@ -1691,22 +1867,43 @@ pub fn legend_rows() -> [(ConfidenceBand, String); 3] {
|
||||
}
|
||||
|
||||
/// Render (or re-render) the source pane's page preview when the shown page changed. One
|
||||
/// synchronous render per page; the texture is cached until then.
|
||||
/// synchronous render per page; the texture is cached until then, and a failed render is not
|
||||
/// retried until the document changes ([`FailedPreview`]).
|
||||
fn ensure_preview(app: &mut PdfCraftApp, page: usize) {
|
||||
let Some(doc) = app.ocr_verify.doc else { return };
|
||||
if app.ocr_verify.preview.as_ref().is_some_and(|p| p.doc == doc && p.page == page && p.texture.is_some()) {
|
||||
let doc = app.ocr_verify.doc;
|
||||
// A preview of another document or page never stands in for this one, even when this
|
||||
// page cannot be rendered.
|
||||
if !app.ocr_verify.preview.as_ref().is_some_and(|p| Some(p.doc) == doc && p.page == page) {
|
||||
app.ocr_verify.preview = None;
|
||||
}
|
||||
let Some(doc) = doc else { return };
|
||||
if app.ocr_verify.preview.as_ref().is_some_and(|p| p.texture.is_some()) {
|
||||
return;
|
||||
}
|
||||
let Some(d) = app.session.get(doc) else { return };
|
||||
let Some(info) = d.info.pages.get(page) else { return };
|
||||
// A page that failed to render fails again until its document changes: don't parse and
|
||||
// render it on every frame.
|
||||
let bytes = Arc::as_ptr(&d.bytes);
|
||||
if app.ocr_verify.preview_failed.as_ref().is_some_and(|f| (f.doc, f.page) == (doc, page) && std::ptr::eq(f.bytes.as_ptr(), bytes)) {
|
||||
return;
|
||||
}
|
||||
#[cfg(test)]
|
||||
tests::PREVIEW_RENDERS.with(|n| n.set(n.get().saturating_add(1)));
|
||||
// Screen scale (the boxes are drawn on top of it); the recognition dpi is irrelevant here.
|
||||
let scale = 2.0_f32.min((900.0 / info.width.max(1.0)).min(1200.0 / info.height.max(1.0))).max(0.2);
|
||||
let config = pdfcraft_render::RenderConfig { password: d.password.as_deref().map(std::sync::Arc::from), ..Default::default() };
|
||||
let mut r = pdfcraft_render::PageRenderer::new(d.bytes.clone(), config);
|
||||
let shot = r.render(pdfcraft_render::RenderRequest { page, kind: pdfcraft_render::RequestKind::Pixels, scale, ..Default::default() });
|
||||
if shot.error.is_some() || shot.width == 0 || shot.height == 0 {
|
||||
return; // keep any earlier preview; a render failure names itself elsewhere
|
||||
let failed = shot.error.is_some() || shot.width == 0 || shot.height == 0;
|
||||
#[cfg(test)]
|
||||
let failed = failed || tests::FAIL_RENDER.with(std::cell::Cell::get);
|
||||
if failed {
|
||||
// A render failure names itself elsewhere.
|
||||
app.ocr_verify.preview_failed = Some(FailedPreview { doc, page, bytes: Arc::downgrade(&d.bytes) });
|
||||
return;
|
||||
}
|
||||
app.ocr_verify.preview_failed = None;
|
||||
let scale = shot.width as f32 / info.width.max(1.0);
|
||||
let texture = app.ctx.as_ref().map(|ctx| {
|
||||
ctx.load_texture(
|
||||
@@ -2089,6 +2286,15 @@ mod tests {
|
||||
use super::*;
|
||||
use pdfcraft_engine::Session;
|
||||
|
||||
thread_local! {
|
||||
/// [`launch`] refuses to start a worker, as the OS can.
|
||||
pub(super) static FAIL_LAUNCH: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
/// How many page renders [`ensure_preview`] started.
|
||||
pub(super) static PREVIEW_RENDERS: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
|
||||
/// [`ensure_preview`]'s render fails, as a page the renderer refuses does.
|
||||
pub(super) static FAIL_RENDER: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
||||
}
|
||||
|
||||
/// A word box that sits where a real placed word would (high on the page, reading left
|
||||
/// to right).
|
||||
fn word(text: &str, conf: Option<f32>) -> PlacedWord {
|
||||
@@ -2437,6 +2643,57 @@ mod tests {
|
||||
assert!(!toast(&mut app).is_empty(), "the failure is said");
|
||||
}
|
||||
|
||||
/// The web build: a Run OCR asked for while the models download runs once they land, rather
|
||||
/// than failing at once with the desktop's "set PDFCRAFT_MODELS" message.
|
||||
#[test]
|
||||
fn a_run_that_waited_for_the_models_starts_when_they_land() {
|
||||
let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]);
|
||||
app.ocr_verify.open = true;
|
||||
app.ocr_verify.awaiting_models = Some(AwaitingModels::Run);
|
||||
with_state(&mut app, |v, app| v.models_landed(app, Ok(())));
|
||||
assert_eq!(app.ocr_verify.awaiting_models, None);
|
||||
// The run is inline here: it either read the blank page (no words) or failed for want
|
||||
// of models on this machine. Either way the old review was replaced by a run.
|
||||
let ran = app.ocr_verify.phase == Phase::RecoverableError || app.ocr_verify.page(0).is_some_and(|p| p.words.is_empty());
|
||||
assert!(ran, "the run started: {:?} {}", app.ocr_verify.phase, app.ocr_verify.message);
|
||||
}
|
||||
|
||||
/// The same for a page or region re-read.
|
||||
#[test]
|
||||
fn a_reread_that_waited_for_the_models_starts_when_they_land() {
|
||||
let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]);
|
||||
app.ocr_verify.open = true;
|
||||
app.ocr_verify.awaiting_models = Some(AwaitingModels::Reread { page: 0, region: None });
|
||||
with_state(&mut app, |v, app| v.models_landed(app, Ok(())));
|
||||
assert_eq!(app.ocr_verify.awaiting_models, None);
|
||||
assert!(app.ocr_verify.reread.is_some(), "the re-read started");
|
||||
}
|
||||
|
||||
/// A failed download is said in the status line, and nothing runs or changes.
|
||||
#[test]
|
||||
fn a_failed_model_download_is_said_and_nothing_runs() {
|
||||
let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]);
|
||||
app.ocr_verify.open = true;
|
||||
app.ocr_verify.awaiting_models = Some(AwaitingModels::Run);
|
||||
with_state(&mut app, |v, app| v.models_landed(app, Err("models/text-detection.onnx: HTTP 404".into())));
|
||||
assert_eq!(app.ocr_verify.awaiting_models, None);
|
||||
assert!(app.ocr_verify.message.contains("Couldn't download") && app.ocr_verify.message.contains("HTTP 404"), "{}", app.ocr_verify.message);
|
||||
assert_eq!(app.ocr_verify.phase, Phase::ReviewReady);
|
||||
assert_eq!(app.ocr_verify.page(0).expect("page").words.len(), 1, "the words are untouched");
|
||||
}
|
||||
|
||||
/// Closing the screen while the models download drops what waited: nothing runs behind it.
|
||||
#[test]
|
||||
fn nothing_waiting_for_the_models_runs_once_the_screen_is_closed() {
|
||||
let (mut app, _) = app_review(vec![vec![word("one", Some(95.0))]]);
|
||||
app.ocr_verify.open = false;
|
||||
app.ocr_verify.awaiting_models = Some(AwaitingModels::Reread { page: 0, region: None });
|
||||
with_state(&mut app, |v, app| v.models_landed(app, Ok(())));
|
||||
assert_eq!(app.ocr_verify.awaiting_models, None);
|
||||
assert!(app.ocr_verify.reread.is_none());
|
||||
assert_eq!(app.ocr_verify.page(0).expect("page").words.len(), 1);
|
||||
}
|
||||
|
||||
/// A re-read that finds nothing is a typed failure: the words stay, the review stays.
|
||||
#[test]
|
||||
fn a_reread_that_finds_nothing_keeps_the_words() {
|
||||
@@ -2827,4 +3084,366 @@ mod tests {
|
||||
let r = pane_region([100.0, 50.0, 200.0, 150.0], &turned, img).expect("on the page");
|
||||
assert_eq!((r.left(), r.top(), r.right(), r.bottom()), (50.0, 25.0, 100.0, 75.0));
|
||||
}
|
||||
|
||||
/// Correct recognized text on a document not yet read keeps the window's tabs, menus and
|
||||
/// toolbar, and its source pane shows the document's page (#6485: the screen took the
|
||||
/// whole window and the page area was blank). Another mode puts the screen away.
|
||||
#[test]
|
||||
fn correct_recognized_text_keeps_the_chrome_and_shows_the_page() {
|
||||
use egui_kittest::kittest::Queryable;
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.open_bytes("scan.pdf", None, bytes.clone()).expect("fixture");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
assert!(h.state_mut().execute("ocr.correct"));
|
||||
h.run_steps(3);
|
||||
let id = h.state().active_ids().expect("a document is open").1;
|
||||
h.get_by_label("Run OCR");
|
||||
assert!(h.query_by_label("Find tools and commands").is_some(), "the toolbar stays");
|
||||
assert!(h.query_by_label("scan.pdf").is_some(), "the document's tab stays");
|
||||
let v = &h.state().ocr_verify;
|
||||
assert_eq!(v.doc, Some(id), "the screen shows the document in front");
|
||||
let preview = v.preview.as_ref().expect("the source page is rendered");
|
||||
assert_eq!((preview.doc, preview.page), (id, 0));
|
||||
assert!(preview.texture.is_some(), "and drawn");
|
||||
// A review with a correction in it, put away by another mode, comes back as it was.
|
||||
let job = h.state_mut().session.ocr_job(id, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
h.state_mut().ocr_verify.load(id, job.guard.clone(), vec![page]);
|
||||
assert!(h.state_mut().ocr_verify.correct((0, 0), "Edited"));
|
||||
h.run_steps(2);
|
||||
h.get_by_label("E-Sign").click();
|
||||
h.run_steps(3);
|
||||
assert!(!h.state().ocr_verify.open, "another mode puts the screen away");
|
||||
assert!(h.state_mut().execute("ocr.correct"));
|
||||
h.run_steps(3);
|
||||
let v = &h.state().ocr_verify;
|
||||
assert!(v.open, "Correct recognized text brings the screen back");
|
||||
assert_eq!((v.phase, v.doc), (Phase::ReviewReady, Some(id)), "with its review");
|
||||
assert_eq!(v.text_of((0, 0)), Some("Edited"), "and the correction in it");
|
||||
}
|
||||
|
||||
/// After a run, the source pane shows a page with recognized words: a page skipped because
|
||||
/// it already has text is passed over, unless every page was skipped.
|
||||
#[test]
|
||||
fn a_finished_run_shows_a_read_page_over_a_skipped_one() {
|
||||
let skipped = |page| OcrPage { already_on_page: 0, page, words: Vec::new(), skipped: Some("has text".into()), notes: Vec::new() };
|
||||
let read = |page| OcrPage { already_on_page: 0, page, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
let mut v = OcrVerify { current: 0, ..Default::default() };
|
||||
v.finished(vec![skipped(0), read(1), read(2)], false);
|
||||
assert_eq!(v.current, 1, "the first page read");
|
||||
let mut v = OcrVerify { current: 2, ..Default::default() };
|
||||
v.finished(vec![skipped(0), read(1), read(2)], false);
|
||||
assert_eq!(v.current, 2, "the current page, when it was read");
|
||||
let mut v = OcrVerify { current: 1, ..Default::default() };
|
||||
v.finished(vec![skipped(0), skipped(1)], false);
|
||||
assert_eq!(v.current, 1, "every page skipped: the current page stays");
|
||||
let mut v = OcrVerify { current: 5, ..Default::default() };
|
||||
v.finished(vec![skipped(0), skipped(1)], false);
|
||||
assert_eq!(v.current, 0, "or the reading's first page, when it lacks the current one");
|
||||
}
|
||||
|
||||
/// A current-page run cancelled before its page was read keeps showing that page.
|
||||
#[test]
|
||||
fn a_run_that_read_no_pages_keeps_its_page() {
|
||||
let mut v = OcrVerify { current: 2, ..Default::default() };
|
||||
v.finished(Vec::new(), true);
|
||||
assert_eq!(v.current, 2);
|
||||
assert_eq!(v.phase, Phase::ReviewReady);
|
||||
}
|
||||
|
||||
/// PAGE pairs the shown page's number in the document with the document's page count, the
|
||||
/// numbering the page list uses: a reading of page 3 alone is "3 / 5", never "3 / 1".
|
||||
#[test]
|
||||
fn page_indicator_numbers_pages_in_the_document() {
|
||||
let mut v = OcrVerify::default();
|
||||
assert_eq!(v.page_indicator(Some(5)), "—", "no reading yet");
|
||||
v.finished(vec![OcrPage { already_on_page: 0, page: 2, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() }], false);
|
||||
assert_eq!(v.page_indicator(Some(5)), "3 / 5");
|
||||
assert_eq!(v.page_indicator(None), "3", "no count when the document is gone");
|
||||
assert_eq!(v.page_indicator(Some(0)), "3");
|
||||
}
|
||||
|
||||
/// The cached preview is dropped once the source moves to a page it is not of, even when
|
||||
/// the new page cannot be rendered: the pane never shows another page's image.
|
||||
#[test]
|
||||
fn a_preview_of_another_page_is_dropped() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let text = app.session.create_from_text("t", "Recognize these scanned words").expect("fixture");
|
||||
let id = app.session.open("t.pdf", None, text, None).expect("fixture");
|
||||
app.ocr_verify.doc = Some(id);
|
||||
ensure_preview(&mut app, 0);
|
||||
assert!(app.ocr_verify.preview.as_ref().is_some_and(|p| (p.doc, p.page) == (id, 0)), "page 1 is rendered");
|
||||
ensure_preview(&mut app, 99);
|
||||
assert!(app.ocr_verify.preview.is_none(), "a page that cannot render shows no image, not page 1's");
|
||||
ensure_preview(&mut app, 0);
|
||||
app.ocr_verify.doc = None;
|
||||
ensure_preview(&mut app, 0);
|
||||
assert!(app.ocr_verify.preview.is_none(), "nor does a pane with no document");
|
||||
}
|
||||
|
||||
/// Closing a document under OCR review, from another tab, Close all or its own tab, keeps it
|
||||
/// open and brings its review forward; other documents close as usual, and once the review
|
||||
/// is rejected the document closes.
|
||||
#[test]
|
||||
fn closing_the_reviewed_document_brings_its_review_forward() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
for name in ["a.pdf", "b.pdf", "c.pdf"] {
|
||||
app.open_bytes(name, None, bytes.clone()).expect("fixture");
|
||||
}
|
||||
let a = app.views.first().expect("a is open").id;
|
||||
let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
app.ocr_verify.load(a, job.guard.clone(), vec![page]);
|
||||
assert!(app.ocr_verify.correct((0, 0), "Edited"));
|
||||
app.active = Some(2);
|
||||
app.request_close_tab(0);
|
||||
assert_eq!(app.views.len(), 3, "the reviewed document stays open");
|
||||
assert_eq!(app.active, Some(0), "and comes to the front");
|
||||
assert!(app.ocr_verify.open, "with its review");
|
||||
assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "as it was");
|
||||
assert!(app.toast.is_some(), "a notice says to accept or reject it first");
|
||||
app.ocr_verify.open = false;
|
||||
app.request_close_tab(2);
|
||||
assert_eq!(app.views.len(), 2, "another document closes as usual");
|
||||
app.active = Some(1);
|
||||
app.close_all();
|
||||
assert_eq!(app.views.len(), 2, "Close all closes nothing while the review is open");
|
||||
assert_eq!(app.active, Some(0));
|
||||
assert!(app.ocr_verify.reject());
|
||||
app.request_close_tab(0);
|
||||
assert_eq!(app.views.len(), 1, "after Reject the document closes");
|
||||
assert!(app.views.iter().all(|v| v.id != a));
|
||||
}
|
||||
|
||||
/// Three open documents with a review of the first one, a word of it corrected.
|
||||
fn reviewing_the_first_of_three() -> (PdfCraftApp, DocId) {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
for name in ["a.pdf", "b.pdf", "c.pdf"] {
|
||||
app.open_bytes(name, None, bytes.clone()).expect("fixture");
|
||||
}
|
||||
let a = app.views.first().expect("a is open").id;
|
||||
let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
app.ocr_verify.load(a, job.guard.clone(), vec![page]);
|
||||
assert!(app.ocr_verify.correct((0, 0), "Edited"));
|
||||
(app, a)
|
||||
}
|
||||
|
||||
/// A close that waited for a save (the user choosing where, while the app runs on) checks
|
||||
/// again for a review started meanwhile: the reviewed document stays open, and Close all and
|
||||
/// Quit stop there.
|
||||
#[test]
|
||||
fn a_close_after_a_save_holds_for_a_review_started_meanwhile() {
|
||||
let (mut app, a) = reviewing_the_first_of_three();
|
||||
let ctx = egui::Context::default();
|
||||
app.active = Some(2);
|
||||
app.close_and_continue(&ctx, 0, crate::CloseRequest::Tab(a));
|
||||
assert_eq!(app.views.len(), 3, "the reviewed document stays open");
|
||||
assert_eq!(app.active, Some(0), "and comes to the front");
|
||||
assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "with its review as it was");
|
||||
app.close_and_continue(&ctx, 1, crate::CloseRequest::All);
|
||||
assert_eq!(app.views.len(), 3, "Close all closes nothing while the review is open");
|
||||
app.note_quit_session();
|
||||
app.close_and_continue(&ctx, 2, crate::CloseRequest::Quit);
|
||||
assert_eq!(app.views.len(), 3, "Quit closes nothing either");
|
||||
assert!(!app.allow_quit && app.quit_session.is_none(), "and the quit is called off");
|
||||
assert!(app.ocr_verify.reject());
|
||||
app.close_and_continue(&ctx, 0, crate::CloseRequest::Tab(a));
|
||||
assert_eq!(app.views.len(), 2, "after Reject the document closes");
|
||||
}
|
||||
|
||||
/// Run OCR on another document while a review is held leaves the review alone and says why.
|
||||
#[test]
|
||||
fn a_run_on_another_document_keeps_the_review() {
|
||||
let (mut app, a) = reviewing_the_first_of_three();
|
||||
FAIL_LAUNCH.with(|f| f.set(true));
|
||||
app.active = Some(1);
|
||||
with_state(&mut app, |v, app| v.start_run(app));
|
||||
FAIL_LAUNCH.with(|f| f.set(false));
|
||||
assert!(app.ocr_verify.message.contains("other document"), "{}", app.ocr_verify.message);
|
||||
assert!(app.ocr_verify.holds_reading_of(a), "the review is still of the first document");
|
||||
assert_eq!(app.ocr_verify.phase, Phase::ReviewReady);
|
||||
assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"));
|
||||
}
|
||||
|
||||
/// The embedding page hears an open review as work it would lose, as the reload warning does,
|
||||
/// and hears it go once the review is rejected.
|
||||
#[test]
|
||||
fn the_embedding_page_hears_an_open_review() {
|
||||
let (mut app, _) = reviewing_the_first_of_three();
|
||||
let reports = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let sink = reports.clone();
|
||||
app.host_dirty = Some(Box::new(move |dirty| sink.lock().expect("not poisoned").push(dirty)));
|
||||
let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1200.0, 800.0)).build_eframe(move |_cc| app);
|
||||
h.run_steps(2);
|
||||
assert_eq!(reports.lock().expect("not poisoned").last(), Some(&true), "an open review is work at risk");
|
||||
assert!(h.state_mut().ocr_verify.reject());
|
||||
h.run_steps(2);
|
||||
assert_eq!(reports.lock().expect("not poisoned").last(), Some(&false), "rejected, nothing is at risk");
|
||||
}
|
||||
|
||||
/// A run of one page shows that page when its reading lands, not page 1.
|
||||
#[test]
|
||||
fn a_finished_run_shows_a_page_it_read() {
|
||||
let mut v = OcrVerify { current: 0, ..Default::default() };
|
||||
let page = OcrPage { already_on_page: 0, page: 2, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
v.finished(vec![page], false);
|
||||
assert_eq!(v.current, 2);
|
||||
assert!(v.page(v.current).is_some());
|
||||
}
|
||||
|
||||
/// A run whose worker thread cannot start leaves nothing running: an empty screen shows why
|
||||
/// and holds no document open, and a review already on the screen stays as it was.
|
||||
#[test]
|
||||
fn a_run_that_cannot_start_keeps_the_review_and_holds_nothing() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
for name in ["a.pdf", "b.pdf"] {
|
||||
app.open_bytes(name, None, bytes.clone()).expect("fixture");
|
||||
}
|
||||
let (a, b) = (app.views[0].id, app.views[1].id);
|
||||
FAIL_LAUNCH.with(|f| f.set(true));
|
||||
app.active = Some(1);
|
||||
with_state(&mut app, |v, app| v.start_run(app));
|
||||
assert_eq!(app.ocr_verify.phase, Phase::RecoverableError, "not left running");
|
||||
assert!(app.ocr_verify.message.contains("could not start"), "{}", app.ocr_verify.message);
|
||||
assert!(app.ocr_verify.can_run(), "Run is enabled again");
|
||||
assert!(!app.ocr_verify.holds_reading_of(b));
|
||||
app.request_close_tab(1);
|
||||
assert_eq!(app.views.len(), 1, "the document closes");
|
||||
// A review on the screen: a run that cannot start leaves it as it was.
|
||||
let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
app.ocr_verify.load(a, job.guard.clone(), vec![page]);
|
||||
assert!(app.ocr_verify.correct((0, 0), "Edited"));
|
||||
with_state(&mut app, |v, app| v.start_run(app));
|
||||
FAIL_LAUNCH.with(|f| f.set(false));
|
||||
assert_eq!(app.ocr_verify.phase, Phase::ReviewReady);
|
||||
assert_eq!(app.ocr_verify.doc, Some(a));
|
||||
assert_eq!(app.ocr_verify.text_of((0, 0)), Some("Edited"), "the correction is kept");
|
||||
assert!(app.ocr_verify.progress.is_none());
|
||||
}
|
||||
|
||||
/// A failed Accept keeps the reading (Run is enabled again), and Reject still throws it
|
||||
/// away without a new run; a failed run that read nothing has nothing to reject.
|
||||
#[test]
|
||||
fn reject_throws_away_a_reading_whose_accept_failed() {
|
||||
let mut s = Session::new();
|
||||
let (id, mut v) = loaded(&mut s, &[Some(95.0)]);
|
||||
assert!(v.correct((0, 0), "Corrected"));
|
||||
s.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("fixture");
|
||||
assert!(v.accept(&mut s).is_err(), "the stale reading is refused");
|
||||
assert_eq!(v.phase, Phase::RecoverableError);
|
||||
assert!(v.holds_reading_of(id), "its document stays open");
|
||||
assert!(v.can_reject());
|
||||
assert!(v.reject());
|
||||
assert_eq!(v.phase, Phase::Idle);
|
||||
assert!(v.pages.is_empty());
|
||||
assert!(!v.holds_reading_of(id), "and can close");
|
||||
let mut failed = OcrVerify::default();
|
||||
failed.failed("no engine");
|
||||
assert!(!failed.can_reject());
|
||||
assert!(!failed.reject(), "a failed run that read nothing has nothing to reject");
|
||||
}
|
||||
|
||||
/// The web build cannot hold a reload or a closed tab the way Quit is held, so the browser's
|
||||
/// leave warning asks while a reading is under review, even when nothing is unsaved, and
|
||||
/// stops asking once the review is rejected.
|
||||
#[test]
|
||||
fn the_web_leave_warning_asks_while_a_review_is_open() {
|
||||
use std::sync::atomic::Ordering;
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
app.open_bytes("a.pdf", None, bytes).expect("fixture");
|
||||
let a = app.views[0].id;
|
||||
let ctx = egui::Context::default();
|
||||
app.guard_quit(&ctx);
|
||||
assert!(!app.unsaved_flag.load(Ordering::Relaxed), "nothing to lose yet");
|
||||
let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
app.ocr_verify.load(a, job.guard.clone(), vec![page]);
|
||||
assert!(app.first_dirty().is_none(), "nothing is unsaved");
|
||||
app.guard_quit(&ctx);
|
||||
assert!(app.unsaved_flag.load(Ordering::Relaxed), "the open review is work a reload would lose");
|
||||
assert!(app.ocr_verify.reject());
|
||||
app.guard_quit(&ctx);
|
||||
assert!(!app.unsaved_flag.load(Ordering::Relaxed), "after Reject there is nothing to lose");
|
||||
}
|
||||
|
||||
/// Quitting, from Quit or by closing the window, while a reading is under review keeps the
|
||||
/// application open and brings the review forward, even when no document has unsaved
|
||||
/// changes; once the review is rejected, the application quits.
|
||||
#[test]
|
||||
fn quitting_brings_an_open_review_forward() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let bytes = Session::new().create_from_text("t", "Recognize these scanned words").expect("fixture").to_vec();
|
||||
for name in ["a.pdf", "b.pdf"] {
|
||||
app.open_bytes(name, None, bytes.clone()).expect("fixture");
|
||||
}
|
||||
let a = app.views[0].id;
|
||||
let job = app.session.ocr_job(a, &[0], OcrSettings::default()).expect("fixture");
|
||||
let page = OcrPage { already_on_page: 0, page: 0, words: vec![word("w", Some(95.0))], skipped: None, notes: Vec::new() };
|
||||
app.ocr_verify.load(a, job.guard.clone(), vec![page]);
|
||||
assert!(app.first_dirty().is_none(), "nothing is unsaved");
|
||||
app.active = Some(1);
|
||||
let ctx = egui::Context::default();
|
||||
assert!(!app.request_quit(&ctx), "Quit is held");
|
||||
assert!(!app.allow_quit);
|
||||
assert_eq!(app.active, Some(0), "the reviewed document comes to the front");
|
||||
assert!(app.ocr_verify.open, "with its review");
|
||||
assert!(app.toast.is_some(), "a notice says to accept or reject it first");
|
||||
assert!(app.close_request.is_none(), "no save question: nothing is unsaved");
|
||||
// Closing the window, as the operating system does.
|
||||
app.active = Some(1);
|
||||
app.ocr_verify.open = false;
|
||||
let mut raw = egui::RawInput::default();
|
||||
raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
|
||||
ctx.begin_pass(raw);
|
||||
app.guard_quit(&ctx);
|
||||
let mut out = ctx.end_pass();
|
||||
out.textures_delta.clear();
|
||||
let commands: Vec<_> = out.viewport_output.values().flat_map(|v| v.commands.iter()).collect();
|
||||
assert!(commands.iter().any(|c| matches!(c, egui::ViewportCommand::CancelClose)), "{commands:?}");
|
||||
assert_eq!(app.active, Some(0));
|
||||
assert!(app.ocr_verify.open);
|
||||
assert_eq!(app.views.len(), 2, "nothing closed");
|
||||
assert!(app.ocr_verify.reject());
|
||||
assert!(app.request_quit(&ctx), "after Reject, Quit goes ahead");
|
||||
assert!(app.allow_quit);
|
||||
}
|
||||
|
||||
/// A page that cannot be rendered is not rendered again on every frame, only once its
|
||||
/// document changes; a page that renders clears the failure.
|
||||
#[test]
|
||||
fn a_page_that_cannot_render_is_not_rendered_on_every_frame() {
|
||||
let mut app = PdfCraftApp::new();
|
||||
let text = app.session.create_from_text("t", "Recognize these scanned words").expect("fixture");
|
||||
let id = app.session.open("t.pdf", None, text, None).expect("fixture");
|
||||
app.ocr_verify.doc = Some(id);
|
||||
FAIL_RENDER.with(|f| f.set(true));
|
||||
let renders = || PREVIEW_RENDERS.with(std::cell::Cell::get);
|
||||
let before = renders();
|
||||
ensure_preview(&mut app, 0);
|
||||
assert!(app.ocr_verify.preview.is_none(), "the page cannot render");
|
||||
assert_eq!(renders(), before + 1);
|
||||
for _ in 0..3 {
|
||||
ensure_preview(&mut app, 0);
|
||||
}
|
||||
assert_eq!(renders(), before + 1, "repaints do not render it again");
|
||||
app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("an edit");
|
||||
ensure_preview(&mut app, 0);
|
||||
assert_eq!(renders(), before + 2, "a changed document is tried again");
|
||||
// A page that renders clears the failure.
|
||||
FAIL_RENDER.with(|f| f.set(false));
|
||||
app.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).expect("an edit");
|
||||
ensure_preview(&mut app, 0);
|
||||
assert!(app.ocr_verify.preview.is_some());
|
||||
assert!(app.ocr_verify.preview_failed.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,521 @@
|
||||
//! Scan & OCR in the browser (wasm32 only).
|
||||
//!
|
||||
//! There are no model files to find: the first time recognition is needed the page downloads
|
||||
//! them from `models/` beside itself (packaging/web/package.sh puts them there) and installs the
|
||||
//! recogniser they load. Pages are then read by Web Workers (apps/pdfcraft-ocr-worker), so the
|
||||
//! page keeps responding and two pages are read at once: the page renders a page, hands its
|
||||
//! pixels to an idle worker and places the lines that come back. Where workers can't run (the
|
||||
//! worker files are missing, the browser refuses them, they never get ready) the page reads the
|
||||
//! pages itself, one per frame.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::collections::VecDeque;
|
||||
use std::rc::Rc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::ocr_route::{Inbox, Router};
|
||||
use pdfcraft_engine::ocr::{
|
||||
DETECTION_MODEL, FileJob, Line, Ocr, OcrImage, OcrPage, OcrSettings, OcrShot, OcrSteps, Opened, RECOGNITION_MODEL, StrategyOutcome,
|
||||
};
|
||||
use wasm_bindgen::{JsCast, JsValue, closure::Closure};
|
||||
|
||||
/// The models are about 12 MB together; a response far larger isn't one of them.
|
||||
const MAX_MODEL_BYTES: u32 = 64 << 20;
|
||||
/// Each worker holds the models and needs about 600 MB while it reads a page (wasm memory never
|
||||
/// shrinks), so a few at most.
|
||||
const MAX_WORKERS: usize = 2;
|
||||
/// A worker that hasn't loaded the models by then is given up on.
|
||||
const START_TIMEOUT_MS: f64 = 60_000.0;
|
||||
/// Workers with nothing to do are closed after this, giving their memory back.
|
||||
const IDLE_CLOSE_MS: f64 = 60_000.0;
|
||||
/// The `<meta>` naming the worker's loader script (xtask/src/web.rs writes it).
|
||||
const WORKER_META: &str = "meta[name=\"pdfcraft-ocr-worker\"]";
|
||||
|
||||
fn now() -> f64 {
|
||||
js_sys::Date::now()
|
||||
}
|
||||
|
||||
// ---- The models -------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum State {
|
||||
NotStarted,
|
||||
Loading,
|
||||
/// Why the last download failed; [`start`] tries again.
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
static STATE: Mutex<State> = Mutex::new(State::NotStarted);
|
||||
|
||||
thread_local! {
|
||||
/// The downloaded model files, kept to hand to each worker.
|
||||
static MODELS: RefCell<Option<[js_sys::Uint8Array; 2]>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
pub fn state() -> State {
|
||||
STATE.lock().unwrap_or_else(|e| e.into_inner()).clone()
|
||||
}
|
||||
|
||||
/// Download and load the models unless that is already happening; repaint when it's done.
|
||||
pub fn start(ctx: egui::Context) {
|
||||
{
|
||||
let mut s = STATE.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if *s == State::Loading {
|
||||
return;
|
||||
}
|
||||
*s = State::Loading;
|
||||
}
|
||||
wasm_bindgen_futures::spawn_local(async move {
|
||||
let next = match load().await {
|
||||
Ok((ocr, files)) => {
|
||||
pdfcraft_engine::ocr::install(ocr);
|
||||
MODELS.with_borrow_mut(|m| *m = Some(files));
|
||||
State::NotStarted
|
||||
}
|
||||
Err(e) => State::Failed(e),
|
||||
};
|
||||
*STATE.lock().unwrap_or_else(|e| e.into_inner()) = next;
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
async fn load() -> Result<(Ocr, [js_sys::Uint8Array; 2]), String> {
|
||||
let detection = fetch(DETECTION_MODEL).await?;
|
||||
let recognition = fetch(RECOGNITION_MODEL).await?;
|
||||
let ocr = Ocr::from_bytes(detection.to_vec(), recognition.to_vec()).map_err(|e| e.to_string())?;
|
||||
Ok((ocr, [detection, recognition]))
|
||||
}
|
||||
|
||||
/// `models/<name>`, relative to the page, so the site works under any path. The names carry no
|
||||
/// content hash and `Ocr::from_bytes` refuses any file but the expected one, so the browser asks
|
||||
/// the server before using a cached copy (`no-cache`): after a model update a stale copy is
|
||||
/// replaced, not refused until the cache expires.
|
||||
async fn fetch(name: &str) -> Result<js_sys::Uint8Array, String> {
|
||||
use wasm_bindgen_futures::JsFuture;
|
||||
let url = format!("models/{name}");
|
||||
let failed = |e: JsValue| format!("{url}: {}", e.as_string().unwrap_or_else(|| format!("{e:?}")));
|
||||
let window = web_sys::window().ok_or("no window")?;
|
||||
let init = web_sys::RequestInit::new();
|
||||
init.set_cache(web_sys::RequestCache::NoCache);
|
||||
let resp = JsFuture::from(window.fetch_with_str_and_init(&url, &init)).await.map_err(failed)?;
|
||||
let resp: web_sys::Response = resp.dyn_into().map_err(failed)?;
|
||||
if !resp.ok() {
|
||||
return Err(format!("{url}: HTTP {}", resp.status()));
|
||||
}
|
||||
let buf = JsFuture::from(resp.array_buffer().map_err(failed)?).await.map_err(failed)?;
|
||||
let bytes = js_sys::Uint8Array::new(&buf);
|
||||
if bytes.length() > MAX_MODEL_BYTES {
|
||||
return Err(format!("{url}: {} bytes is too large for a model", bytes.length()));
|
||||
}
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
// ---- The workers ------------------------------------------------------------------------------
|
||||
|
||||
enum Slot {
|
||||
Starting,
|
||||
Loading,
|
||||
Idle,
|
||||
/// Reading the page sent under this id.
|
||||
Busy(u64),
|
||||
Dead,
|
||||
}
|
||||
|
||||
/// What a worker said (see apps/pdfcraft-ocr-worker for the messages).
|
||||
enum Event {
|
||||
Ready,
|
||||
Loaded,
|
||||
Lines(u64, Result<Vec<Line>, String>),
|
||||
Failed(Option<u64>, String),
|
||||
Died,
|
||||
}
|
||||
|
||||
struct Member {
|
||||
worker: web_sys::Worker,
|
||||
slot: Slot,
|
||||
_on_message: Closure<dyn FnMut(web_sys::MessageEvent)>,
|
||||
_on_error: Closure<dyn FnMut(JsValue)>,
|
||||
}
|
||||
|
||||
struct Pool {
|
||||
members: Vec<Member>,
|
||||
events: Rc<RefCell<Vec<(usize, Event)>>>,
|
||||
started: f64,
|
||||
/// When a page last went to or came back from a worker: the idle time runs from here.
|
||||
last_used: f64,
|
||||
/// Which run each page sent belongs to: single-file and multiple-file OCR share the workers.
|
||||
runs: Router<Result<Vec<Line>, String>>,
|
||||
}
|
||||
|
||||
thread_local! {
|
||||
static POOL: RefCell<Option<Pool>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// How many workers to start: one core stays with the page.
|
||||
fn worker_count() -> usize {
|
||||
let cores = web_sys::window().map_or(1.0, |w| w.navigator().hardware_concurrency());
|
||||
let cores = if cores.is_finite() && cores >= 1.0 { cores as usize } else { 1 };
|
||||
cores.saturating_sub(1).clamp(1, MAX_WORKERS)
|
||||
}
|
||||
|
||||
fn field(data: &JsValue, key: &str) -> JsValue {
|
||||
js_sys::Reflect::get(data, &key.into()).unwrap_or(JsValue::UNDEFINED)
|
||||
}
|
||||
|
||||
fn message(fields: &[(&str, &JsValue)]) -> JsValue {
|
||||
let o = js_sys::Object::new();
|
||||
for (k, v) in fields {
|
||||
let _ = js_sys::Reflect::set(&o, &(*k).into(), v);
|
||||
}
|
||||
o.into()
|
||||
}
|
||||
|
||||
impl Event {
|
||||
fn parse(data: &JsValue) -> Event {
|
||||
let id = field(data, "id").as_f64().filter(|v| v.fract() == 0.0 && *v >= 0.0).map(|v| v as u64);
|
||||
let text = |key: &str| field(data, key).as_string().unwrap_or_default();
|
||||
match field(data, "type").as_string().as_deref() {
|
||||
Some("ready") => Event::Ready,
|
||||
Some("loaded") => Event::Loaded,
|
||||
Some("lines") => match id {
|
||||
Some(id) => Event::Lines(id, pdfcraft_engine::ocr::lines_from_json(&text("lines"), "ocrs").map_err(|e| e.to_string())),
|
||||
None => Event::Failed(None, "a result without its page".into()),
|
||||
},
|
||||
Some("failed") => Event::Failed(id, text("error")),
|
||||
_ => Event::Failed(id, "an unknown message".into()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Pool {
|
||||
/// Start the workers. None start (and the page reads by itself) when the page doesn't name
|
||||
/// the worker script or the browser refuses it.
|
||||
fn start(ctx: &egui::Context) -> Pool {
|
||||
let t = now();
|
||||
let mut pool = Pool { members: Vec::new(), events: Rc::new(RefCell::new(Vec::new())), started: t, last_used: t, runs: Router::default() };
|
||||
let url = web_sys::window()
|
||||
.and_then(|w| w.document())
|
||||
.and_then(|d| d.query_selector(WORKER_META).ok().flatten())
|
||||
.and_then(|m| m.get_attribute("content"));
|
||||
let Some(url) = url else { return pool };
|
||||
let options = web_sys::WorkerOptions::new();
|
||||
options.set_type(web_sys::WorkerType::Module);
|
||||
for i in 0..worker_count() {
|
||||
let Ok(worker) = web_sys::Worker::new_with_options(&url, &options) else { break };
|
||||
let (events, ctx2) = (pool.events.clone(), ctx.clone());
|
||||
let on_message = Closure::<dyn FnMut(web_sys::MessageEvent)>::new(move |e: web_sys::MessageEvent| {
|
||||
events.borrow_mut().push((i, Event::parse(&e.data())));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
let (events, ctx2) = (pool.events.clone(), ctx.clone());
|
||||
let on_error = Closure::<dyn FnMut(JsValue)>::new(move |_| {
|
||||
events.borrow_mut().push((i, Event::Died));
|
||||
ctx2.request_repaint();
|
||||
});
|
||||
worker.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
|
||||
worker.set_onerror(Some(on_error.as_ref().unchecked_ref()));
|
||||
pool.members.push(Member { worker, slot: Slot::Starting, _on_message: on_message, _on_error: on_error });
|
||||
}
|
||||
pool
|
||||
}
|
||||
|
||||
/// Whether any worker is, or may yet be, able to read pages.
|
||||
fn usable(&self) -> bool {
|
||||
self.members.iter().any(|m| !matches!(m.slot, Slot::Dead))
|
||||
}
|
||||
|
||||
fn idle(&self) -> Option<usize> {
|
||||
self.members.iter().position(|m| matches!(m.slot, Slot::Idle))
|
||||
}
|
||||
|
||||
fn busy(&self) -> bool {
|
||||
self.members.iter().any(|m| matches!(m.slot, Slot::Busy(_)))
|
||||
}
|
||||
|
||||
fn kill(&mut self, i: usize) {
|
||||
if let Some(m) = self.members.get_mut(i) {
|
||||
m.worker.terminate();
|
||||
m.slot = Slot::Dead;
|
||||
}
|
||||
}
|
||||
|
||||
/// Act on what the workers said; the pages they finished go to the inboxes of the runs that
|
||||
/// sent them.
|
||||
fn update(&mut self) {
|
||||
let mut done = Vec::new();
|
||||
let events: Vec<_> = self.events.borrow_mut().drain(..).collect();
|
||||
for (i, event) in events {
|
||||
let Some(m) = self.members.get_mut(i) else { continue };
|
||||
let busy = if let Slot::Busy(id) = m.slot { Some(id) } else { None };
|
||||
match event {
|
||||
Event::Ready if matches!(m.slot, Slot::Starting) => {
|
||||
let sent = MODELS.with_borrow(|files| {
|
||||
let [d, r] = files.as_ref()?;
|
||||
m.worker.post_message(&message(&[("type", &"models".into()), ("detection", d), ("recognition", r)])).ok()
|
||||
});
|
||||
if sent.is_some() {
|
||||
m.slot = Slot::Loading;
|
||||
} else {
|
||||
self.kill(i);
|
||||
}
|
||||
}
|
||||
Event::Loaded if matches!(m.slot, Slot::Loading) => m.slot = Slot::Idle,
|
||||
Event::Lines(id, lines) if busy == Some(id) => {
|
||||
m.slot = Slot::Idle;
|
||||
done.push((id, lines));
|
||||
}
|
||||
Event::Failed(Some(id), e) if busy == Some(id) => {
|
||||
m.slot = Slot::Idle;
|
||||
done.push((id, Err(e)));
|
||||
}
|
||||
// A worker that can't load the models, or fails other than on its page, is out.
|
||||
Event::Failed(..) | Event::Died => {
|
||||
if let Some(id) = busy {
|
||||
done.push((id, Err("the text recognition worker stopped".into())));
|
||||
}
|
||||
self.kill(i);
|
||||
}
|
||||
// Stale (a page from a cancelled run) or out of order: nothing to do.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if now() - self.started > START_TIMEOUT_MS {
|
||||
for i in 0..self.members.len() {
|
||||
if self.members.get(i).is_some_and(|m| matches!(m.slot, Slot::Starting | Slot::Loading)) {
|
||||
self.kill(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The idle time starts once a worker is done with its page, not when it was given it (a
|
||||
// page can take most of a minute to read).
|
||||
if !done.is_empty() {
|
||||
self.last_used = now();
|
||||
}
|
||||
for (id, lines) in done {
|
||||
self.runs.deliver(id, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hand `shot` to worker `i` for the run reading into `inbox`; its id, or `None` if it
|
||||
/// couldn't be sent.
|
||||
fn send(&mut self, i: usize, image: &OcrImage, inbox: &Inbox<Result<Vec<Line>, String>>) -> Option<u64> {
|
||||
let m = self.members.get_mut(i)?;
|
||||
let id = self.runs.send(inbox, |id| {
|
||||
let pixels = js_sys::Uint8Array::from(image.rgba());
|
||||
let msg = message(&[
|
||||
("type", &"page".into()),
|
||||
("id", &JsValue::from_f64(id as f64)),
|
||||
("width", &image.width().into()),
|
||||
("height", &image.height().into()),
|
||||
("pixels", &pixels),
|
||||
]);
|
||||
// The pixels move to the worker rather than being copied again.
|
||||
m.worker.post_message_with_transfer(&msg, &js_sys::Array::of1(&pixels.buffer())).is_ok()
|
||||
})?;
|
||||
m.slot = Slot::Busy(id);
|
||||
self.last_used = now();
|
||||
Some(id)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Pool {
|
||||
fn drop(&mut self) {
|
||||
for m in &self.members {
|
||||
m.worker.terminate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the workers once they have been idle a while (called when no recognition is running).
|
||||
/// The page only draws a frame when something happens, so one is asked for when the idle time
|
||||
/// runs out: otherwise the workers' memory stays taken until the next click or key.
|
||||
pub fn close_idle_workers(ctx: &egui::Context) {
|
||||
POOL.with_borrow_mut(|pool| {
|
||||
// Answers still arriving for a cancelled run free their workers (and are dropped).
|
||||
if let Some(p) = pool.as_mut() {
|
||||
p.update();
|
||||
}
|
||||
let Some(p) = pool.as_ref().filter(|p| !p.busy()) else { return };
|
||||
let idle = now() - p.last_used;
|
||||
if idle > IDLE_CLOSE_MS {
|
||||
*pool = None;
|
||||
} else {
|
||||
// `as` saturates (and a NaN becomes 0); a little past the limit, so the check passes.
|
||||
let left = (IDLE_CLOSE_MS - idle).max(0.0) as u64;
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(left.saturating_add(50)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Reading pages ----------------------------------------------------------------------------
|
||||
|
||||
/// One run of [`OcrSteps`] through the workers, advanced a step per frame by [`Pages::pump`].
|
||||
pub struct Pages {
|
||||
steps: OcrSteps,
|
||||
/// Pages with a worker: id, position in the run, the page (its pixels gone to the worker).
|
||||
reading: Vec<(u64, usize, OcrShot)>,
|
||||
/// Where the workers' answers for this run's pages arrive (dropped with the run, so a
|
||||
/// cancelled run's late answers are let go).
|
||||
inbox: Inbox<Result<Vec<Line>, String>>,
|
||||
found: Vec<(usize, OcrPage)>,
|
||||
next: usize,
|
||||
rendered_all: bool,
|
||||
}
|
||||
|
||||
impl Pages {
|
||||
pub fn new(steps: OcrSteps) -> Pages {
|
||||
Pages { steps, reading: Vec::new(), inbox: Inbox::default(), found: Vec::new(), next: 0, rendered_all: false }
|
||||
}
|
||||
|
||||
pub fn total(&self) -> usize {
|
||||
self.steps.total()
|
||||
}
|
||||
|
||||
pub fn done(&self) -> usize {
|
||||
self.found.len()
|
||||
}
|
||||
|
||||
/// Collect what the workers read and hand out the next page (rendering at most one a call,
|
||||
/// so a frame stays short). Returns the pages, in order, once all are read or `cancel` is set
|
||||
/// (the pages read so far; answers still coming are ignored).
|
||||
pub fn pump(&mut self, cancel: bool, ctx: &egui::Context) -> Option<Vec<OcrPage>> {
|
||||
POOL.with_borrow_mut(|pool| {
|
||||
let pool = pool.get_or_insert_with(|| Pool::start(ctx));
|
||||
pool.update();
|
||||
for (id, lines) in self.inbox.take() {
|
||||
if let Some(k) = self.reading.iter().position(|r| r.0 == id) {
|
||||
let (_, order, shot) = self.reading.swap_remove(k);
|
||||
self.found.push((order, place(&shot, lines)));
|
||||
}
|
||||
}
|
||||
if cancel {
|
||||
return Some(self.finish());
|
||||
}
|
||||
let worker = pool.idle();
|
||||
if !self.rendered_all && (worker.is_some() || !pool.usable()) {
|
||||
match self.steps.next_page() {
|
||||
None => self.rendered_all = true,
|
||||
Some(Err(skipped)) => {
|
||||
let order = self.take_order();
|
||||
self.found.push((order, skipped));
|
||||
}
|
||||
Some(Ok(mut shot)) => {
|
||||
let order = self.take_order();
|
||||
match worker.zip(shot.image()).and_then(|(i, image)| pool.send(i, image, &self.inbox)) {
|
||||
Some(id) => {
|
||||
// The worker has its own copy; drop this one.
|
||||
shot.take_image();
|
||||
self.reading.push((id, order, shot));
|
||||
}
|
||||
// No worker can take it: read it here, with the job's engines.
|
||||
None => {
|
||||
let page = match pdfcraft_engine::ocr::recognizers(self.steps.settings()) {
|
||||
Ok(r) => shot.read(&r),
|
||||
Err(e) => shot.place(Err(e)),
|
||||
};
|
||||
self.found.push((order, page));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(self.rendered_all && self.reading.is_empty()).then(|| self.finish())
|
||||
})
|
||||
}
|
||||
|
||||
fn take_order(&mut self) -> usize {
|
||||
self.next += 1;
|
||||
self.next - 1
|
||||
}
|
||||
|
||||
fn finish(&mut self) -> Vec<OcrPage> {
|
||||
let mut found = std::mem::take(&mut self.found);
|
||||
found.sort_by_key(|f| f.0);
|
||||
found.into_iter().map(|f| f.1).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Place what a worker read: its one engine's reading (every strategy reads that way without a
|
||||
/// second engine, and the web build has only ocrs).
|
||||
fn place(shot: &OcrShot, lines: Result<Vec<Line>, String>) -> OcrPage {
|
||||
shot.place(lines.map(|lines| StrategyOutcome { lines, ..Default::default() }))
|
||||
}
|
||||
|
||||
/// Recognize text in multiple files, one file at a time through [`Pages`]; each searchable copy
|
||||
/// downloads when it's done. The counts are what `ocr_ui::batch_summary` reports.
|
||||
pub struct Batch {
|
||||
files: VecDeque<(String, Vec<u8>)>,
|
||||
settings: OcrSettings,
|
||||
current: Option<(String, Box<FileJob>, Pages)>,
|
||||
total: usize,
|
||||
pub ok: usize,
|
||||
pub skipped: usize,
|
||||
pub words: usize,
|
||||
pub failed: Vec<String>,
|
||||
/// The low-confidence notes, one per file that has such words.
|
||||
pub notes: Vec<String>,
|
||||
/// After a cancel: how many files were not run.
|
||||
pub not_run: Option<usize>,
|
||||
}
|
||||
|
||||
impl Batch {
|
||||
pub fn new(files: Vec<(String, Vec<u8>)>, settings: OcrSettings) -> Batch {
|
||||
let total = files.len();
|
||||
Batch {
|
||||
files: files.into(),
|
||||
settings,
|
||||
current: None,
|
||||
total,
|
||||
ok: 0,
|
||||
skipped: 0,
|
||||
words: 0,
|
||||
failed: Vec::new(),
|
||||
notes: Vec::new(),
|
||||
not_run: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Files finished so far.
|
||||
pub fn done(&self) -> usize {
|
||||
self.total.saturating_sub(self.files.len() + usize::from(self.current.is_some()))
|
||||
}
|
||||
|
||||
/// Advance; `true` once every file is done. A cancel is honoured between files: the file
|
||||
/// in flight finishes, the rest are counted as not run.
|
||||
pub fn pump(&mut self, cancel: bool, ctx: &egui::Context) -> bool {
|
||||
let Some((name, _, pages)) = self.current.as_mut() else {
|
||||
if cancel && !self.files.is_empty() {
|
||||
self.not_run = Some(self.files.len());
|
||||
self.files.clear();
|
||||
}
|
||||
let Some((name, bytes)) = self.files.pop_front() else { return true };
|
||||
match FileJob::open(&name, Arc::new(bytes), None, self.settings.clone()) {
|
||||
Ok(Opened::Skipped(_)) => self.skipped += 1,
|
||||
Ok(Opened::Read(mut file)) => match file.job.take() {
|
||||
Some(job) => self.current = Some((name, file, Pages::new(job.steps()))),
|
||||
None => self.failed.push(format!("{name}: the document could not be read")),
|
||||
},
|
||||
Err(e) => self.failed.push(format!("{name}: {e}")),
|
||||
}
|
||||
return false;
|
||||
};
|
||||
let Some(found) = pages.pump(false, ctx) else { return false };
|
||||
let name = std::mem::take(name);
|
||||
let Some((_, file, _)) = self.current.take() else { return false };
|
||||
let saved = file.finish(found).and_then(|r| {
|
||||
crate::editing::download(&name, &r.bytes)?;
|
||||
Ok(r)
|
||||
});
|
||||
match saved {
|
||||
Ok(r) => {
|
||||
self.ok += 1;
|
||||
self.words += r.words();
|
||||
if let Some(note) = crate::ocr_ui::low_confidence_note(&name, &r, self.settings.low_confidence_threshold) {
|
||||
self.notes.push(note);
|
||||
}
|
||||
}
|
||||
Err(e) => self.failed.push(format!("{name}: {e}")),
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -10464,29 +10464,18 @@ fn escape_in_an_open_paragraph_selects_its_box_for_delete() {
|
||||
#[test]
|
||||
fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
|
||||
let table = b"BT /F1 14 Tf 1 0 0 1 40 200 Tm (Revenue 153,300.00) Tj 1 0 0 1 40 170 Tm (Costs 382,110.00) Tj ET";
|
||||
let stream = |dict: &str, data: &[u8]| [format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat();
|
||||
let objs: Vec<Vec<u8>> = 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 400 300] /Contents 4 0 R /Resources << /XObject << /Fm1 5 0 R >> >> >>".to_vec(),
|
||||
stream("<<", b"q /Fm1 Do Q"),
|
||||
stream("<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>", table),
|
||||
pdf_stream("<<", b"q /Fm1 Do Q"),
|
||||
pdf_stream(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 400 300] /Group << /S /Transparency >> /Resources << /Font << /F1 6 0 R >> >>",
|
||||
table,
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
];
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
let pdf = pdf_of_objects(&objs);
|
||||
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
@@ -10521,6 +10510,89 @@ fn ungroup_in_the_artwork_menu_makes_a_forms_text_editable() {
|
||||
assert_eq!(blocks(&h), 0, "undo puts the form back");
|
||||
}
|
||||
|
||||
/// A stream object `dict` (its opening `<<` and entries, without `/Length` or `>>`) holding `data`.
|
||||
fn pdf_stream(dict: &str, data: &[u8]) -> Vec<u8> {
|
||||
[format!("{dict} /Length {} >>\nstream\n", data.len()).as_bytes(), data, b"\nendstream"].concat()
|
||||
}
|
||||
|
||||
/// A PDF of `objs`, numbered from 1, with the first as its catalog.
|
||||
fn pdf_of_objects(objs: &[Vec<u8>]) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.7\n".to_vec();
|
||||
let mut offsets = Vec::new();
|
||||
for (i, o) in objs.iter().enumerate() {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{} 0 obj\n", i + 1).as_bytes());
|
||||
pdf.extend_from_slice(o);
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
let xref = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
|
||||
for o in offsets {
|
||||
pdf.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref}\n%%EOF\n", objs.len() + 1).as_bytes());
|
||||
pdf
|
||||
}
|
||||
|
||||
/// Grouped text is not prepared while the pages are only looked at: entering Edit text and
|
||||
/// scrolling change nothing. One click on the group's text prepares that page, as one undoable
|
||||
/// step, and opens the paragraph clicked for typing. A click on a grouped logo selects it, as a
|
||||
/// click on any picture does, and changes nothing; a drag from the text group prepares nothing.
|
||||
#[test]
|
||||
fn one_click_on_grouped_text_opens_it_for_editing() {
|
||||
let objs: Vec<Vec<u8>> = 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 400 300] /Contents 4 0 R /Resources << /XObject << /Body 5 0 R /Logo 7 0 R >> >> >>".to_vec(),
|
||||
pdf_stream("<<", b"q /Body Do Q q 1 0 0 1 300 20 cm /Logo Do Q"),
|
||||
pdf_stream(
|
||||
"<< /Type /XObject /Subtype /Form /BBox [0 0 280 300] /Resources << /Font << /F1 6 0 R >> >>",
|
||||
b"BT /F1 24 Tf 1 0 0 1 40 200 Tm (Grouped words) Tj ET",
|
||||
),
|
||||
b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>".to_vec(),
|
||||
pdf_stream("<< /Type /XObject /Subtype /Form /BBox [0 0 60 60] /Resources << >>", b"0 0 1 rg 0 0 60 60 re f"),
|
||||
];
|
||||
let pdf = pdf_of_objects(&objs);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
|
||||
let mut app = PdfCraftApp::new();
|
||||
app.set_option("language", "en").unwrap();
|
||||
app.open_bytes("grouped.pdf", None, pdf.clone()).expect("opens");
|
||||
app
|
||||
});
|
||||
h.run_steps(4);
|
||||
let id = h.state().views[0].id;
|
||||
let doc = |h: &Harness<'static, PdfCraftApp>| {
|
||||
let d = h.state().session.get(id).unwrap();
|
||||
(d.text_blocks(0).len(), d.page_images(0).len(), d.can_undo().map(str::to_string))
|
||||
};
|
||||
assert!(h.state_mut().execute("edit.edit_text"));
|
||||
h.run_steps(6);
|
||||
assert_eq!(doc(&h), (0, 2, None), "nothing is prepared while the page is only shown");
|
||||
let r = h.state().views[0].page_screen_rect(0).expect("on screen");
|
||||
// User space (x, y) on the page, which is 400 by 300 points and upright.
|
||||
let at = |x: f32, y: f32| r.left_top() + egui::vec2(r.width() * x / 400.0, r.height() * (300.0 - y) / 300.0);
|
||||
|
||||
click_at(&mut h, at(330.0, 50.0));
|
||||
let logo = h.state().session.get(id).unwrap().page_images(0).iter().position(|i| i.name == "Logo");
|
||||
assert_eq!(h.state().views[0].image_selection.as_ref().map(|s| (s.page, s.index)), logo.map(|i| (0, i)), "the logo is selected");
|
||||
assert_eq!(doc(&h), (0, 2, None), "a click on artwork without text changes nothing");
|
||||
assert!(h.state().views[0].line_editor.is_none());
|
||||
|
||||
drag(&mut h, at(60.0, 208.0), at(120.0, 150.0));
|
||||
assert_eq!(doc(&h).0, 0, "a drag on the text group prepares nothing");
|
||||
|
||||
click_at(&mut h, at(60.0, 208.0));
|
||||
h.run_steps(2);
|
||||
assert_eq!(doc(&h), (1, 1, Some("Prepare grouped text".into())), "the page's grouped text is prepared, in one step");
|
||||
let editing = h.state().views[0].line_editor.as_ref().map(|e| e.text.clone());
|
||||
assert_eq!(editing.as_deref(), Some("Grouped words"), "the paragraph clicked opens for typing");
|
||||
|
||||
assert!(h.state_mut().execute("edit.undo"));
|
||||
h.run_steps(3);
|
||||
let (blocks, images, _) = doc(&h);
|
||||
assert_eq!((blocks, images), (0, 2), "undo puts the group back");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outward_image_grip_drags_grow_on_rotated_pages() {
|
||||
for rotation in [0, 90, 180, 270] {
|
||||
|
||||
@@ -377,7 +377,7 @@ fn ocr_verify_screen_controls_keyboard_and_accept() {
|
||||
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).
|
||||
// The screen's controls are all present (no dead controls).
|
||||
h.get_by_label("Run OCR");
|
||||
h.get_by_label("Reject");
|
||||
h.get_by_label("Next uncertain");
|
||||
|
||||
@@ -87,7 +87,7 @@ fn ocr_verify_screen_controls_keyboard_and_accept() {
|
||||
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).
|
||||
// The screen's controls are all present (no dead controls).
|
||||
h.get_by_label("Run OCR");
|
||||
h.get_by_label("Reject");
|
||||
h.get_by_label("Next uncertain");
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# Editing existing text and source subsets
|
||||
|
||||
In **Edit text and images**, text drawn inside a Form XObject (grouped artwork) is edited
|
||||
with one click: clicking its text prepares that page in an undoable **Prepare grouped text**
|
||||
step and opens the paragraph clicked for typing. Nothing is prepared while pages are only
|
||||
shown or scrolled. A drag still moves the selected group as a whole; a click on grouped
|
||||
artwork that preparation wouldn't open (a logo, a refused group) selects it as before, and
|
||||
changes nothing. To select a text group for moving, right-click it. While a paragraph is open
|
||||
or another edit is waiting, the click selects the group instead, as before; the next click
|
||||
prepares it. The group's original operators, clip, placement and resources become page
|
||||
content; shared forms on other pages stay unchanged. Ungroup (the artwork's right-click menu)
|
||||
draws one placement in place with the same code. Headless callers use `text_prepare`, then
|
||||
list `text_paragraphs` and call `text_edit`.
|
||||
This edits existing glyph operators; it adds neither a cover rectangle nor an Add Text box.
|
||||
Preparation refuses knockout/blended groups, groups that blend in a colour space other than
|
||||
the page group's (DeviceRGB on a page that names none), forms whose default
|
||||
colour spaces differ from the page's, tagged forms, forms drawn inside text objects or
|
||||
unfinished paths, groups whose nested content can't be checked and unreadable content. Forms
|
||||
that draw only images or vector artwork are left grouped. Decoding is capped at 16 MiB, 64
|
||||
placements and eight expansion levels per page; text grouped deeper is reported. Unsupported
|
||||
groups remain intact: `text_prepare` lists each with its reason, and the repair log records
|
||||
them when the page is prepared. A narrowly fingerprinted legacy THSarabunPSK exporter map can
|
||||
also be repaired in copied page fonts, only when every glyph the subset kept advances as its
|
||||
assumed Thai character does; `text_prepare` repairs it also for text that was never grouped,
|
||||
and the repair log records both preparation and map recovery.
|
||||
|
||||
Click a text region in **Edit a PDF → Edit text and images**. One editor contains its
|
||||
source rows and mixed styles. Embedded TrueType and OpenType/CFF faces are used in memory;
|
||||
source font programs, colour, size and baseline anchors stay intact. A textless preview
|
||||
@@ -44,6 +68,44 @@ this works only when that build input actually contains the full face. This chan
|
||||
add a font binary or change the release font pin. Fonts shared by releases belong in
|
||||
`storytold/craft-fonts` under the repository's asset rules.
|
||||
|
||||
**Keep source fonts** retains the existing Thai family, weight and point size. For legacy
|
||||
THSarabunPSK subsets, PdfCraft reconstructs an editor-only Unicode cmap from the PDF's
|
||||
Unicode and CID-to-glyph maps. HarfRust uses the retained Thai positional glyphs, and the
|
||||
saved text refers to the original PDF font resources. Several subsets of the same exact
|
||||
face can supply Thai and punctuation glyphs, including a Thai run whose clusters are split
|
||||
across subsets, and a subset whose glyph for a character is empty is skipped; Regular and
|
||||
Bold stay separate. Source font
|
||||
programs are never modified or replaced. Text settings, including character/word spacing,
|
||||
horizontal scale, rise, color and paint mode, remain in the PDF text state.
|
||||
|
||||
A source subset that lacks a needed contextual glyph produces an error asking for the
|
||||
complete original face or an explicit replacement. Cross-family replacement requires an
|
||||
explicit choice. The document-font menu offers **Noto Sans Thai**: pass its `text_fonts`
|
||||
resource (normally `builtin:thai`) as `text_edit.font_resource` or a range's `font_resource`. This replacement
|
||||
requires the optional craft-fonts Thai input and currently uses Regular. A matching Noto
|
||||
subset can still be completed by that same bundled family when it is Regular and upright; a
|
||||
subset its name or descriptor marks bold or italic needs an explicit replacement.
|
||||
|
||||
The bundled variable Thai face is written as bounded Type 3 glyph outlines, with shaped
|
||||
vowel/tone positions and logical Unicode text, rather than embedded as a static font.
|
||||
Saved paragraphs can be reopened and edited again without installing a font. Modern Thai
|
||||
shaping of vowels and tone marks uses the source's GPOS mark positioning (a `mark` or `mkmk` feature in the script and language system the shaper
|
||||
selects for Thai, not just a GPOS table); GSUB substitution is optional, as specified by
|
||||
[the OpenType Thai model](https://learn.microsoft.com/en-us/typography/script-development/thai).
|
||||
Verified legacy Thai PUA fonts use positional substitutions instead of requiring GPOS; their
|
||||
alternates are used only when each kept one has a mark's zero advance (or, for the two
|
||||
consonant forms, an advance) and, where the PDF's own map names its character, the character
|
||||
that alternate stands for. A THSarabunPSK program with its own Thai mark
|
||||
positioning, such as a complete face loaded with Load font, is shaped by that layout instead,
|
||||
still in its own codes. One without it takes the legacy path only when every Thai glyph it keeps
|
||||
is where the legacy glyph order puts it, and is otherwise shaped as an ordinary source font. A
|
||||
subset needs only the glyphs its new text uses. When no subset can show the new text in its
|
||||
existing codes, a complete program of the same face and style (one added with Load font, CFF
|
||||
included) shapes the whole span with its own layout, Latin rows too, rather than the edit being
|
||||
refused; a Regular program never completes text the source marks bold or italic.
|
||||
Mixed contextual style runs and dictionary word segmentation remain limited. No font binary
|
||||
or release pin changes are needed.
|
||||
|
||||
When mixed-style text needs outline fallback, characters using the same actual fallback face
|
||||
share Type 3 fonts, with at most 240 distinct glyphs per font. Adjacent characters with the same
|
||||
font and source style are written as one run. Latin characters that the source font can encode
|
||||
@@ -89,7 +151,9 @@ On a scanned page, recognised text (Scan & OCR) is an invisible layer over the p
|
||||
text groups it into phrases: words joined by a word space, with a table cell, a tab stop and each
|
||||
scanned line kept apart. Applying an edit draws the new text visibly in the scan's ink colour (or
|
||||
the one chosen) and first covers the old words with the paper colour sampled around them. Each
|
||||
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping. The
|
||||
word keeps its position and width, and a longer phrase grows rightwards rather than wrapping.
|
||||
OCR sizes each word from its own box, so a phrase's words can come out several points apart: the
|
||||
editor and the rewrite set the whole phrase in one size, the median of its words' sizes. The
|
||||
cover is one flat colour, and the text uses the layer's Helvetica, not the scanned typeface.
|
||||
This applies to any invisible text (rendering mode 3), not just a PdfCraft OCR layer: editing
|
||||
invisible text, such as another OCR tool's layer, makes the rewritten paragraph visible over a
|
||||
|
||||
@@ -80,7 +80,7 @@ features total).
|
||||
|
||||
| Target | Acrobat | PdfCraft | Fidelity | Tests and evidence |
|
||||
|---|---|---|---|---|
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and borderless inferred tables; positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
|
||||
| Word (`.docx`) | yes, layout-preserving | flowing paragraphs and inferred tables (bordered only along the grid lines the page draws); positioned text/artwork only for pages with rotated text or text overlapping an image/watermark, with per-page sizes | **basic** (#773 synthetic invoice regression covered; exact fonts, vector rules and general layout fidelity remain incomplete) | `create.export-docx` partial |
|
||||
| Word 97–2003 (`.doc`) | yes | no | none | |
|
||||
| Excel (`.xlsx`), XML Spreadsheet 2003 | yes | no | none | `create.export-xlsx` planned |
|
||||
| PowerPoint (`.pptx`) | yes | slides per page: title from largest text, body text, images, tables (no slide overflow reflow / normAutofit; first page sets presentation dimensions) | basic | `create.export-pptx` partial |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Hardware and platform parity
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (web OCR shipped: Web Workers with SIMD, checked in a browser; earlier: web printing through the browser's print dialog; earlier: shared craft-print spooler with passing Windows CI; Windows printers row and 1:1 touchpad scrolling, #756, #759) · **Target:** Adobe Acrobat Pro (Acrobat DC, continuous track 26.002.21931, macOS)
|
||||
|
||||
Each hardware feature Acrobat Pro uses, per platform, ours against Acrobat's. Acrobat runs on macOS
|
||||
and Windows (plus a limited web viewer/editor in Document Cloud); PdfCraft runs on macOS, Windows,
|
||||
@@ -34,12 +34,14 @@ printing are counted in their feature areas). **Platforms ≈ 70% ready, 15–30
|
||||
| Windows (x64, x86, ARM64) | yes (x64, ARM64) | yes | signed installers | — |
|
||||
| Linux | no | yes | AppImage, deb, rpm, Flatpak, tarball | Chinese faces in Flatpak (#689) |
|
||||
| FreeBSD | no | yes | package | — |
|
||||
| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR, crash recovery, PKCS #12 signing, hosted deployment |
|
||||
| Web | Acrobat online (cloud) | yes (WASM, offline in the browser) | static site | OCR as fast as native (Web Workers with SIMD read a page about 17× slower), crash recovery, PKCS #12 signing, hosted deployment |
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check |
|
||||
| 2026-10-12 | minor | Web platform row: OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, partial); what's left is speed |
|
||||
| 2026-10-11 | minor | Web printers row: printing through the browser's print dialog (gap 4 closed); Web platform row no longer lists printing |
|
||||
| 2026-10-10 | major | Created from the code, `vendor/README.md`, `.github/workflows/release.yml`, the print crate's README and user issues |
|
||||
| 2026-10-10 | minor | Windows printing shipped (#756): the Windows row's gap is closed; the web row keeps "printing" |
|
||||
|
||||
+8
-2
@@ -1,5 +1,7 @@
|
||||
# PdfCraft roadmap: milestones and what's next
|
||||
|
||||
> **Last reviewed:** 2026-11-07 | **Last updated:** 2026-11-07 | **Change:** patch (M7 #2964 / #3185: grouped source text and Thai font/style preservation, with reported deep or unsafe groups and a per-glyph check of the Thai map repair; estimates unchanged)
|
||||
|
||||
> **Last reviewed:** 2026-10-28 | **Last updated:** 2026-10-28 | **Change:** minor (M10, #4119 partial: Enhance scanned file cleans scanned pages in place: background removal, edge shadow removal, despeckle; estimates unchanged)
|
||||
> **Last reviewed:** 2026-11-03 | **Last updated:** 2026-11-03 | **Change:** patch (M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged)
|
||||
|
||||
@@ -241,7 +243,7 @@ total matches the parity estimate.
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Refreshed against upstream `99941daa` (2026-10-15).<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions. XML/XFDF exports preserve tabs and line breaks in names and comment metadata. Plain AcroForm XML imports retain all direct text around comments and processing instructions, including CDATA and character references. CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Multi-select choice fields write ascending `/I` option indices while preserving `/V` selection order; synthetic forms/engine regressions cover reverse selection and save/reopen. CSV export preserves carriage returns in names and values. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions; single-glyph RTL ligature spans (including lam-alef) join their surrounding word. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. Done since: #3187: Added-image automation normalizes extreme signed rotation angles before combining them with the current orientation; integer bounds, history, pixels, save/reopen and MCP have regressions. Done since: #2592: Styled fallback fonts batch up to 240 glyphs per actual face and retain source styles. Budget refusal and distinct same-BaseFont descriptor weights have mutation-checked regressions; coverage and complex mixed runs remain limited. Done since: #5657 page-mark operations are atomic across selected pages, including shared/inherited resources and existing unsaved edits. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks PR #2155 candidate: editor-owned crops preserve original clips through group moves; Arrange preserves added-item stream ownership and refuses unsafe scope/state/tag extraction; image handles and flips follow page orientation; general transforms and Unicode script toggles have headless tools. Clipboard, mixed-size formatting, Home pins and reloadable installed-font discovery have review regressions. Latest follow-up uses explicit image-paste events, refreshes the mixed-size display, and documents script normalization with Undo. Further review covers inherited Form text and mask chronology, scoped transform composition, affine resize/rotation pivots, paste/selection ownership, no-op scripts and page-menu permissions. Done since: #5835 a right-click on an image in crop mode keeps the crop and opens no menu, also over overlapping and rotated images. Thai editing (#2964, #3185): supported Form text is editable; legacy Thai retains source fonts and text settings. Cross-family replacement is explicit; unsafe groups and mixed contextual styles remain partial. |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Done since: Edit text preserves Arabic/Hebrew `/ActualText` typing order, with font-independent regressions. Done since (#1116): editable text regions grouped across compatible words and subset fonts, one rich-text editor over a background with the source text removed, embedded sfnt faces reused as editor faces, and visible (clipped) bounds for cropped-image selection and resize. Exclusive crops now resize across nested states and content streams, with shared clips fixed.<br>Done since: #594 ordinary paragraph drags preserve overlapping source text; UI/engine regressions, native save/reopen, independent-reader checks and full CI passed in the Linux test environment. #1200 candidate: local GoToR links open in desktop tabs and through root-confined automation; appearance-only edits preserve existing actions. Named destinations remain unapplied and `/NewWindow` is a tab hint. #1316: replacing a line or paragraph keeps a neighbouring line whose glyph box overlaps when the baselines differ; a second copy on the same baseline is still replaced. Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). #3212: Headers, Bates numbers, watermarks and added text stay visible after unfinished marked content, without revealing hidden content. Tracked editable text distinguishes repeated glyph spacing from word gaps, with synthetic paragraph/object regressions. Missing: editing existing text and images in place (the longest pole), watermark print/screen visibility and repeat |
|
||||
| M6 | Forms + JavaScript | 160–320 | 50% | 40–75 | Done: filling all field types with regenerated appearances; Prepare a form (every field type, multi-select/shared properties, move/resize/delete, Field Properties General/Appearance/Position/Options/Format/Validate/Calculate); Acrobat's AF format/keystroke/validate/calculate functions and simplified field notation run natively in Acrobat's event order; tab order; push-button actions; flatten; agent tools. Form data exchange (FDF, XFDF, XML, CSV, text) done; XFDF values retain text across XML comments and processing instructions.<br>Done since: JavaScript engine (boa, sandboxed) running custom keystroke/validate/calculate/format and button scripts, document JavaScripts, console. Missing: Actions tab, wider object model (annotations, layers, dialogs), XFA<br>Done since: Existing simple field-font encodings and Differences are respected when generating appearances; an unencodable value uses a separate Helvetica appearance font without changing the source font. Synthetic reopen regressions cover punctuation, subset fallback and multiline text. Exact font metrics/layout and broader Unicode fallback remain incomplete. |
|
||||
@@ -249,7 +251,7 @@ total matches the parity estimate.
|
||||
| M7 | Content editing (text, images, header/footer, watermark) | 250–500 | 20% | 100–180 | Done: header & footer, watermarks, backgrounds, Bates; added text and images that stay editable (move, resize, format, rotate, flip, crop, replace); links (Link tool, Link Properties, create from URLs, remove all). Missing: editing existing text and images in place (the longest pole), image/PDF watermarks |
|
||||
| M8 | Security + redaction | 100–180 | 68% | 15–30 | Done: opening protected documents, permissions, Protect Using Password, Remove security; redaction (mark text/areas/pages, Search & Redact with patterns, apply removing glyphs, image pixels, vectors, XObject content, annotations and fields, verification, full rewrite on save); Remove hidden information and Sanitize.<br>Done since: redaction codes (U.S. FOIA and Privacy Act sets as overlay text, in Redaction properties and `redact_mark`). Redaction cleans indirect and form structure references, preserving surviving form tags. Redaction retires inherited resource dictionaries, so a parent `/Pages` can't keep a removed XObject reachable; unredacted pages are left untouched. Image redaction removes alternate images and unsupported masks only on affected images. Changed marked-content spans lose replacement descriptions; untouched spans keep theirs. Hidden-text cleanup retires unused named descriptions across inherited/shared page and form resources (64 focused tests and scoped Rust 1.96 Clippy/wasm checks pass; publication CI pending). Retained text/image-bearing or unreadable tiling-pattern cells now make Apply refuse atomically after a bounded check of rewritten resources; fully removed cells still apply, and cross-page retention is checked. The redaction audit counts a filled cover that hides half of any one word of a long line (#2662), applied or not. Phone-pattern redaction also matches labelled Italian geographic numbers with `+39` and a slash after the area code (#3205 follow-up); unlabelled international slash numbers remain unsupported. Missing: certificate security, custom redaction code sets and pattern locales, DCT re-encoding |
|
||||
| M9 | Signatures (PAdES, validation) | 160–280 | 60% | 40–70 | Done: new `sign` crate (DER, X.509, CMS, PKCS #12 on RustCrypto; aws-lc-rs for RSA private keys); PAdES B-B signing (visible, invisible, existing fields, certification with DocMDP); validation with trust store and changes-after-signing classification; self-signed digital IDs; Signatures panel, message bar, sign dialogs; agent tools. Checked with pdfsig and OpenSSL.<br>Done since: macOS Keychain and Windows Current User Personal store signing (software-backed CNG keys), certificate viewer, signed documents protected from rewrites, page validation overlays separate from original signature artwork, placed in the widget's bottom-right corner as it lies on the page, at a quarter of its shorter side and 48 screen points at most, so they annotate the artwork from a corner instead of covering the middle of it and turn with the page and the view (a field falling under 32 screen points, as a page does when zoomed out to fit, keeps its outline), snapshot tabs that reuse instead of multiplying, named-layer signature appearances redrawn for their real state (#79; display, the CLI's render, exports and printing, never saves; captions in the interface language, Chinese in English; the badge stays). Missing: timestamps (B-T), LTV (DSS, OCSP, CRL), FieldMDP, smart cards, PKCS #11 |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Missing: OCR languages beyond Latin, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
|
||||
| M10 | OCR, create, export, print | 200–350 | 33% | 120–215 | Done: create from blank/text/PNG/JPEG/TIFF (multi-page)/GIF/BMP; export PNG/JPEG/TIFF and text; Print (Acrobat's sizing, n-up, booklet, poster, comments & forms, preview, the CUPS and Windows spoolers, print-ready PDF).<br>Done since: embedded/72/custom DPI choices for image imports; export all images; OCR (searchable image for pages, ranges and multiple files); single-sided cut-and-stack imposition with cut marks, through Print and doc_print; architectural/ANSI/large ISO sheet presets, Match page at 100% and PDF-scale preview (#599 follow-up); Create PDF from multiple files (PDFs, images and text, edited as pages in the grid); OCR can also read pictures on pages that have text, without adding that text twice (#1028). Model staging fetches SHA-256-verified ONNX detection/recognition files after the upstream RTEN URL brownout; runtime discovery prefers complete ONNX pairs per directory, then complete legacy RTEN pairs, without mixing formats or changing directory precedence. Done since: Word invoice export uses borderless inferred tables, floating positioned text/artwork only where text overlaps an image or is rotated, and per-page sizes, covered by synthetic invoice automation regressions (#773); printing on the web through the browser's print dialog, sheets drawn by PdfCraft (gap 4). Lossless image export applies per-component Decode, including Indexed mapping (#1201 partial). Existing 16-bit precision and color-management limits remain. #3532 partial: vector printing retains existing appearances with absent Subtype through a print-owned Form copy; source artwork stays unchanged. #3120: Office export reads form XObjects 50 levels deep, as the renderer does, and up to 50 000 a page; pages past that are listed in `truncated_pages`. Word table cells use the grid columns they span, including cells after a merged cell. Done since: text in a table slot covered by another cell's span remains as paragraph text in Word, HTML, RTF and PowerPoint. Web OCR downloads its models on first use and reads in Web Workers with SIMD (`ocr.ocr-web`, shipped). Missing: OCR languages beyond Latin, web OCR near native speed, editable-text OCR output, full Office layout fidelity/import. Done since: dotted-leader contents entries stay in separate complete paragraphs (#1757) #2156 candidate: last selected print paper persists across sessions; other print preferences unchanged. Image export applies soft-mask Decode opacity intervals before resizing, including indirect entries and inverted ranges. Vector printing gives annotation appearances unoccupied XObject names so existing page artwork survives. Done since: Enhance scanned file cleans scanned pages in place (background removal, edge shadow removal, despeckle; the OCR text layer and comments stay; #4119 partial). |
|
||||
| M11 | Optimize, preflight, PDF/A/X/UA, print production | 200–350 | 18% | 80–140 | Done: new `optimize` crate: Reduce File Size and the PDF Optimizer (images measured where drawn, bicubic downsampling, JPEG/ZIP recompression only when smaller, discard objects and user data, Flate clean-up, resource merging, object streams). Target-version compatibility is partial: newer content is not detected or converted. Content stream boundaries preserve comments, split operands and graphics state when image fragments merge. Image downsampling includes page-local `/UserUnit` scaling when measuring physical resolution. Done since: reject invalid image and soft-mask dimensions with checked sizing (#2178); black-and-white images stored losslessly as CCITT Group 4 when smaller, averaged down to 300 ppi above 450 (halftones aside), Group 3 stored again as Group 4, and black-and-white RGB and indexed images that use only palette entries 0 and 1 packed to one bit. Missing: fonts and transparency panels, space audit, preflight, PDF/A/X/UA |
|
||||
| M12 | Accessibility, compare, measure, search, XFA | 200–380 | 30% | 90–160 | Done: new `a11y` crate with the Accessibility Checker (all 32 rules, report, Fix/Skip/Explain, options dialog and results panel, agent tools). Locatable figure, annotation and form findings highlight their page bounds. Character encoding accepts Identity-H/V with Adobe CJK collections and predefined CJK CMaps independently (#3673); per-glyph validation and embedded-font extraction remain separate.<br>Done since: 2D distance, perimeter and area measurements with persistent viewport calibration, snapping, live information and CSV export; a bounded, text-only auto-tag proposal tool for one page. Marking a figure decorative rewrites only that stream (#1324). An XFA radio item whose value contains a space is written back unchanged (#1319). Done since: #2569: Growable multiline XFA text reflows after edits, with undo/redo, save/reopen and print coverage; oversized fields still become read-only page text. Structured-data synchronization warnings retain accepted whitespace without relayout from stale data. Done since: #2572: Every repeated page-master button resolves its own field to the shared XFA click script, including print buttons. Done since: #2570: Dynamic XFA rich-text fields carry exData/maxLength into AcroForm MaxLen for reported and enforced Unicode character limits, retained after save/reopen. Done since: #2589: Active XFA skips PDF-container fallback scripts while retaining requested messages and saved scripts; ordinary/static/unlaid-out documents and explicit console/field calls keep their existing behavior. Missing: writing proposed tags and parent tree, field autotag, visual Tags/Order/Content panels, Reading Order tool, compare, geospatial/3D measurement, search index, XFA<br>Reading colours: pending page backgrounds follow the configured colour (#2059). |
|
||||
| M13 | Automation (MCP, Action Wizard, CLI) + AI providers | 60–120 | 45% | 25–45 | Done: MCP resources (document info, text, page images); headless tool table (123 tools incl. signing, optimizing, initial view, links, stamps, data exchange, comment review, forms authoring and scripts, redaction, sanitize, print, add content), opt-in MCP server over stdio, CLI `run`/`tools` (closed stdout pipes exit cleanly), UI control channel with drag. Missing: Action Wizard, AI providers<br>Done since: CLI run and MCP reject missing, empty, repeated and option-valued --root arguments before any tool/script execution or MCP initialization. A single-hyphen directory name remains valid; synthetic CLI regressions preserve a would-be overwritten file. `text_find` also caps the per-page match ranges it materializes at the remaining result limit plus one continuation match. CLI robustness scans visit each directory once through overlapping roots and symlink aliases. |
|
||||
@@ -316,6 +318,8 @@ Estimates unchanged.
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-11-07 | patch | M10 `ocr.ocr-web` ported onto #977's engines and preprocessing: the page prepares each page, a Web Worker runs ocrs, the downloaded models join the engine cache. Estimates unchanged. |
|
||||
| 2026-11-05 | patch | M7 #2964 / #3185: grouped text and Thai source-font preservation; limitations and estimates unchanged. |
|
||||
| 2026-11-03 | minor | M11: Optimize's JBIG2 images share a symbol dictionary (`/JBIG2Globals`), up to 16 at a time and when smaller (`pdfcraft_filters::encode_jbig2_shared`): 41–46% smaller on rendered text in proportional fonts, 10% in Courier |
|
||||
| 2026-11-03 | patch | M5 #5850 review: indirect typewriter intent and composer edits preserve outer whitespace; movement regression commits by outside click; estimates unchanged. |
|
||||
| 2026-11-03 | patch | M5 #5850: Enter adds Fill & Sign line breaks; outside clicks commit, Escape cancels, and saved multiline appearances retain their text and height; estimates unchanged. |
|
||||
@@ -386,6 +390,8 @@ Estimates unchanged.
|
||||
| 2026-10-15 | minor | M6: CSV export and spreadsheet merging quote tabs, and import distinguishes quoted tabs from TSV delimiters without changing field identities. Estimates unchanged. |
|
||||
| 2026-10-13 | minor | M4: rotation queries and edits resolve indirect page-tree values; inherited, negative and large rotations verified through both saves. Estimates unchanged. |
|
||||
| 2026-10-14 | minor | M12: pending page backgrounds respect reading colours (#2059); estimates unchanged. |
|
||||
| 2026-10-12 | minor | M10 `ocr.ocr-web` shipped: Web Worker OCR checked by hand in a browser. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M10 `ocr.ocr-web` (partial): the web build downloads the OCR models on first use (the web zip ships them) and reads pages in Web Workers built with SIMD. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: valid AcroForm XML element names with original PDF field names preserved; saved-PDF collision and Unicode regressions. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M6: preserve carriage returns in exported CSV field names and values; saved-PDF round-trip coverage. Estimates unchanged. |
|
||||
| 2026-10-12 | minor | M7 local candidate: text drafts grow and save their width; single visual rows grow across style boundaries; both text tools retype added items and Add Text edits source text. Added-item textless previews are read-only and covered by UI/tool regressions. Estimates unchanged. |
|
||||
|
||||
@@ -191,7 +191,7 @@ features total and are shown for orientation; the others add to it.
|
||||
| Localization | ≈ 68% over the 12 key languages | 5% | 35–60 | additional | 22 catalogs + English (23 interface languages); native craft-fonts builds embed a Simplified Chinese face (LXGW WenKai Lite) but the web build has none; Arabic labels stay logical with explicit RTL direction and mirrored stock chrome, while native/browser/IME workflows are unqualified; no Hindi, Indonesian, Korean | [localization-parity.md](localization-parity.md) |
|
||||
| Performance | ≈ 40% | 6% | 25–45 | additional | 500-page smooth scrolling and large-file memory work (#307) done; no performance budgets (`misc.performance-budgets`), no lazy loading of GB files; blurry text at 1080p/1440p (#730) | [gaps.md](gaps.md) |
|
||||
| Stability | ≈ 50% | 8% | 45–80 | additional | Never-crash rules and nightly fuzzing; 245 open issues on 2026-10-10, many of them `fix(...)` reports of wrong results in shipped features (the #799–#821 batch is fixed) | [gaps.md](gaps.md) |
|
||||
| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; we lack web OCR and web recovery | [hardware-parity.md](hardware-parity.md) |
|
||||
| Platforms | ≈ 70% | 5% | 15–30 | additional | macOS (signed, notarized), Windows x64/x86/ARM64 (signed), Linux (AppImage, deb, rpm, Flatpak), FreeBSD, web (WASM). Acrobat has no Linux, FreeBSD or full web editor; web OCR reads in Web Workers but is far slower than native, and we lack web recovery | [hardware-parity.md](hardware-parity.md) |
|
||||
| Ecosystem and plug-ins | ≈ 30% | 1% | 10–20 | within features | No plug-in SDK or folder-level JavaScript; Action Wizard partial. Agent automation (CLI, MCP, UI control channel) has no Acrobat equivalent | [gaps.md](gaps.md) |
|
||||
| AI features | ≈ 0% (in scope) | 1% | 20–40 | within features | Acrobat's AI Assistant is cloud-only (out of scope). Local, opt-in provider interface (`misc.ai-provider`) not started | [gaps.md](gaps.md) |
|
||||
| **Total (weighted sum)** | **≈ 50%** | 100% | **≈ 780–1,400** | | | |
|
||||
@@ -303,6 +303,8 @@ licensed OCR or AI model.
|
||||
| 2026-10-26 | minor | `optimize.mono-compression` became partial (CCITT Group 4), counted on the table's own measurement: K Optimize and standards 15 shipped and 11 partial of 58 (35.3%), no longer listing CCITT as missing; breadth 57.0% unweighted (423 + 75 of 808), mainstream 63.3% (292 + 45 of 497). Estimates unchanged. |
|
||||
| 2026-10-25 | minor | Localization current state: Arabic catalogs stay logical, Arabic/Kurdish labels take an explicit RTL direction, and stock chrome mirrors; native/browser RTL input, IME and complex workflows remain unqualified. Native Simplified Chinese uses LXGW WenKai Lite; the web build has no Hans face. Scores and estimates unchanged. |
|
||||
| 2026-10-20 | minor | M12 #3673: the Character encoding rule accepts Identity-H/V fonts with Adobe CJK collections, or a predefined non-Identity CMap, while rejecting unknown names and foreign registries. Synthetic checker and read-only automation regressions passed after failing before the fix; per-glyph mapping validation and embedded-font extraction remain separate. Breadth, readiness and hours unchanged. |
|
||||
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`) shipped after a browser check; scores not re-judged |
|
||||
| 2026-10-12 | minor | Web OCR (`ocr.ocr-web`, partial): the web zip serves the models, Recognize Text downloads them on first use, and pages are read in Web Workers built with SIMD. Platforms text updated; scores not re-judged |
|
||||
| 2026-10-12 | minor | Exclusive image crops resize with their pixels across nested states, intervening operators and content streams; shared clips remain fixed. Synthetic engine/tool regressions and private image save/reopen checks; estimates unchanged. |
|
||||
| 2026-10-11 | minor | Web printing shipped (gap 4): Hardware, Platforms and L Print text updated. Text only: scores, readiness and hours not re-judged |
|
||||
| 2026-10-11 | minor | Contributor fork: document-font selection and subset completion, CFF, style ranges, contextual/bidi save/reopen and all-page Chinese keyboard acceptance; curve/hole hit testing (shared clips stay fixed, as on main). Scope remains bounded; percentages unchanged |
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Windows print jobs rejected before submission
|
||||
|
||||
A report on PdfCraft 0.19.0 described the preview closing without output while Edge printed the same PDF. On the investigated Windows machine, the HUAWEI CV81Z driver accepted the PrintTicket (paper, grayscale, duplex and collation), and its capabilities XML parsed successfully.
|
||||
|
||||
The native submission test failed before queueing: `StorageFile::GetFileFromPathAsync` returned `0x80070005` for the temporary PDF. Ordinary desktop file access could read it. This isolates one reproducible failure; it does not establish the cause of every report of an empty print queue.
|
||||
|
||||
The winprint loader uses `CreateRandomAccessStreamOnFile` with read-only access, then calls `PdfDocument::LoadFromStreamAsync`. The stream is backed by the job file and reads on demand. There is no whole-job `InMemoryRandomAccessStream` copy, `DataWriter`, transfer loop or per-chunk `StoreAsync().join()`. The path is made absolute and embedded NUL characters are rejected before calling the native API. The temporary file remains alive throughout rendering and submission through craft-print's existing lifetime management. PDF decoding and rendering can still allocate memory; the change removes the loader's full-file copy, not all printing memory use.
|
||||
|
||||
Upstream loader: https://github.com/storytold/winprint.rs/pull/15 (`51934db3`). Dependency follow-up: https://github.com/storytold/craft-libs/pull/636 (`7ee35a9f`, includes merged #605). PdfCraft updates only its normal `craft-print` dependency. There is no global patch or personal-fork source. The loader revision descends from `64e7afd4`, preserving the multi-threaded Direct2D factory fix for #5006. Upstream reviews are still required before release.
|
||||
|
||||
The public `craft_print::rgb` GDI transport remains pinned to main's `64e7afd4` revision. It is independent of the internal PDF-loader pin `51934db3`: retaining the public source identity avoids incompatible Rust types when a downstream application also uses that GDI revision directly. PdfCraft resolves one GDI package at that stable revision.
|
||||
|
||||
## Evidence
|
||||
|
||||
- Original Windows loader: synthetic A4 submission failed at PDF loading with `0x80070005`. The earlier memory-stream prototype submitted the same HUAWEI job; physical output was not independently confirmed.
|
||||
- Final file-backed loader: five focused tests pass, covering a hidden PDF, read-only stream behavior, reading the final byte of a 256 MiB file, a missing file, and a path containing NUL.
|
||||
- The hidden-file test was observed failing with the original StorageFile loader. The read-only test was observed failing with the earlier in-memory implementation and passing with the final loader.
|
||||
- Isolated craft-print suite with the final loader and multi-threaded Direct2D source: 63 passed, 3 hardware tests ignored. These diagnostic tests are not a full build of the final dependency graph; the disabled PDFium downloader build dependencies were omitted locally.
|
||||
- The file-backed loader at `b334ebbb` was subsequently submitted natively to the HUAWEI CV81Z; the reporter confirmed physical output of the synthetic A4 page. This used an isolated diagnostic workspace and writable TEMP directory, not the packaged UI. See https://github.com/storytold/winprint.rs/pull/15#issuecomment-6704470691.
|
||||
- The current loader pin `51934db3` also includes the review repairs: standard-library NUL validation, GetPage(0) before document/file cleanup, RAII temporary-file cleanup, and the explicit WinRT feature. In a complete Windows checkout with normal build dependencies, five loader tests and 14 Print Schema tests passed; injected failures left no temporary files behind.
|
||||
- Full final-graph build/quality gates must be rerun for the updated pins. Packaged-UI and wider printer/duplex qualification remain unverified; single-page output does not establish those.
|
||||
|
||||
When reporting a different failure, include the PdfCraft version, printer/driver name, displayed error and whether a job appears in the Windows queue. Saving the print-ready PDF and printing it in another viewer remains a workaround until the fix ships.
|
||||
|
||||
The loader pin also includes a bounded 30-second wait in the large-file native stream regression test. Production `PdfDocument::LoadFromStreamAsync` still has no dedicated timeout; the terminal print-completion timeout does not cover document loading. Current dependency-graph CI must pass before merging; earlier-head results do not qualify this update.
|
||||
@@ -8,7 +8,9 @@ static site in `pdfcraft-web-<version>/`:
|
||||
| `index.html` | The page. It loads everything through relative URLs. |
|
||||
| `pdfcraft-web-<hash>.js` | wasm-bindgen glue (generated, ES module) |
|
||||
| `pdfcraft-web-<hash>_bg.wasm` | The app, about 13 MB, or 5 MB with compression |
|
||||
| `pdfcraft-ocr-worker-<hash>.js`, `_bg.wasm`, `_loader.js` | The text recognition worker (about 3 MB), started only when OCR runs |
|
||||
| `_headers`, `.htaccess` | Sample header rules for Netlify/Cloudflare Pages and Apache |
|
||||
| `models/` | The text recognition (OCR) models, about 12 MB, with their licences (CC-BY-SA-4.0) |
|
||||
|
||||
There is no server-side code. Upload the folder's contents anywhere that serves static files.
|
||||
|
||||
@@ -55,6 +57,26 @@ location /pdfcraft/ {
|
||||
|
||||
Local test: `python3 -m http.server 8765` inside the folder, then open http://localhost:8765/.
|
||||
|
||||
## Text recognition (OCR)
|
||||
|
||||
Scan & OCR ▸ Recognize Text downloads `models/text-detection.onnx` and
|
||||
`models/text-recognition.onnx`, relative to the page, the first time it is opened in a visit.
|
||||
Keep `models/` beside `index.html`. The app loads only the files whose SHA-256 matches
|
||||
`ATTRIBUTION.toml`, so a wrong file fails like a missing one. Their file names carry no content
|
||||
hash, so the app asks the server before using a cached copy (`no-cache`): the server must answer
|
||||
that check correctly (an ETag, or a `Last-Modified` that changes whenever the file does). Without
|
||||
the models, everything else works and the Recognize Text dialog says the download failed.
|
||||
|
||||
Pages are read in Web Workers (`pdfcraft-ocr-worker-<hash>_loader.js`, a module worker), so the
|
||||
app keeps responding, and two pages are read at once on a machine with three or more cores. Each
|
||||
worker needs about 600 MB while it reads; idle workers close after a minute. The workers need no
|
||||
special headers, but a `Content-Security-Policy` must allow them (`worker-src 'self'`, or
|
||||
`script-src 'self'`). Where they can't start, the app reads the pages itself, a page at a time,
|
||||
and doesn't respond while each page is read.
|
||||
|
||||
From a source build, `trunk build --release` doesn't fetch the models. Add them with
|
||||
`cargo xtask models dist/web/models` (after the build, which replaces `dist/web`).
|
||||
|
||||
## Embedding in a page (iframe)
|
||||
|
||||
```html
|
||||
@@ -162,3 +184,7 @@ PdfCraft renders with wgpu. It uses **WebGPU** when the browser has it and falls
|
||||
For example: `<iframe src="https://example.com/pdfcraft/?webgl" ...>`.
|
||||
|
||||
A browser with neither WebGPU nor WebGL2 gets a message in place of the app.
|
||||
|
||||
The build uses WebAssembly SIMD (`.cargo/config.toml`), which reads a scanned page about 2.4
|
||||
times faster. Every current browser has it (Chrome and Edge 91, Firefox 89, Safari 16.4 and
|
||||
later). Older browsers can't load the app.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
#
|
||||
# Usage: packaging/web/package.sh [--skip-build]
|
||||
#
|
||||
# Needs: trunk (brew install trunk / cargo install trunk --locked) and the wasm32-unknown-unknown
|
||||
# target. The zip holds a self-contained static site in pdfcraft-web-<version>/ that works
|
||||
# from any URL path and inside an <iframe>. Hosting notes: packaging/web/README.md.
|
||||
# Needs: trunk (brew install trunk / cargo install trunk --locked), the wasm32-unknown-unknown
|
||||
# target and network access for the OCR models (cargo xtask models). The zip holds a
|
||||
# self-contained static site in pdfcraft-web-<version>/ that works from any URL path and inside
|
||||
# an <iframe>. Hosting notes: packaging/web/README.md.
|
||||
set -euo pipefail
|
||||
# shellcheck source=../env.sh
|
||||
. "$(dirname "${BASH_SOURCE[0]}")/../env.sh"
|
||||
@@ -29,6 +30,8 @@ WORK="$CARGO_TARGET_DIR/web-package"
|
||||
rm -rf "$WORK"
|
||||
mkdir -p "$WORK/$NAME"
|
||||
cp -R "$SITE/." "$WORK/$NAME/"
|
||||
# Scan & OCR downloads its models from models/ beside index.html the first time it's used.
|
||||
stage_models "$WORK/$NAME/models"
|
||||
# Sample server configs (MIME type, caching, compression); harmless where unused.
|
||||
cp "$HERE/_headers" "$HERE/.htaccess" "$WORK/$NAME/"
|
||||
cp "$HERE/README.md" "$WORK/$NAME/HOSTING.md"
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-1
@@ -90,6 +90,7 @@ pub const TABLE: &[(&str, Class)] = &[
|
||||
("pdfcraft", Class::Exempt),
|
||||
("cli", Class::Exempt),
|
||||
("web", Class::Exempt),
|
||||
("ocr-worker", Class::Exempt),
|
||||
("xtask", Class::Exempt),
|
||||
];
|
||||
|
||||
@@ -356,7 +357,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn apps_exempt() {
|
||||
for app in ["pdfcraft", "pdfcraft-cli", "pdfcraft-web", "xtask"] {
|
||||
for app in ["pdfcraft", "pdfcraft-cli", "pdfcraft-web", "pdfcraft-ocr-worker", "xtask"] {
|
||||
assert!(check(&[c(app, &[("egui", Normal, false), ("pdfcraft-ui-egui", Normal, true)])]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ mod netcheck;
|
||||
mod parity;
|
||||
mod screenshots;
|
||||
mod version;
|
||||
mod web;
|
||||
|
||||
type Command = fn(&[String]) -> anyhow::Result<()>;
|
||||
|
||||
@@ -55,6 +56,11 @@ const COMMANDS: &[(&str, &str, Command)] = &[
|
||||
("screenshots", "Regenerate the README screenshots in docs/images/ and their ATTRIBUTION entries", screenshots::run),
|
||||
("models", "Fetch the OCR models (ATTRIBUTION.toml kind = \"model\") into assets/models/, verified by SHA-256", assets::models),
|
||||
("demo-pdf", "Build dist/demo/pdfcraft-showcase.pdf (needs Google Chrome or Chromium)", demo_pdf::run),
|
||||
(
|
||||
"web-worker-link",
|
||||
"Hash the web build's OCR worker files and name the worker in index.html (a Trunk post-build hook runs it)",
|
||||
web::worker_link,
|
||||
),
|
||||
];
|
||||
|
||||
fn version_cmd(args: &[String]) -> anyhow::Result<()> {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
//! `cargo xtask web-worker-link [DIR]`: give the web build's OCR worker hashed file names and
|
||||
//! tell the page where it is.
|
||||
//!
|
||||
//! Trunk names the page's files after a hash of their contents, so hosts can cache them forever,
|
||||
//! but leaves a worker's files under their plain names (`pdfcraft-ocr-worker.js`, `_bg.wasm`,
|
||||
//! `_loader.js`) and writes nothing into `index.html` for it. A browser holding an old worker
|
||||
//! script with a new `.wasm` would break, so Trunk runs this after each build
|
||||
//! (apps/pdfcraft-web/Trunk.toml, `TRUNK_STAGING_DIR`): it renames the three files to
|
||||
//! `pdfcraft-ocr-worker-<hash>…`, updates the names inside them, and adds
|
||||
//! `<meta name="pdfcraft-ocr-worker" content="./pdfcraft-ocr-worker-<hash>_loader.js">`, which
|
||||
//! crates/ui-egui/src/workflows/ocr_web.rs reads.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
|
||||
/// The `<meta>` name the page looks for.
|
||||
const META: &str = "pdfcraft-ocr-worker";
|
||||
const STEM: &str = "pdfcraft-ocr-worker";
|
||||
|
||||
pub fn worker_link(args: &[String]) -> Result<()> {
|
||||
let dir = match args.first() {
|
||||
Some(d) => PathBuf::from(d),
|
||||
None => PathBuf::from(std::env::var_os("TRUNK_STAGING_DIR").context("give the site's folder, or run from a Trunk hook (TRUNK_STAGING_DIR)")?),
|
||||
};
|
||||
let read = |name: &str| std::fs::read(dir.join(name)).with_context(|| format!("reading {}", dir.join(name).display()));
|
||||
let worker = Worker { js: read(&format!("{STEM}.js"))?, wasm: read(&format!("{STEM}_bg.wasm"))?, loader: read(&format!("{STEM}_loader.js"))? };
|
||||
let hashed = worker.hashed()?;
|
||||
for (name, bytes) in &hashed.files {
|
||||
write(&dir.join(name), bytes)?;
|
||||
}
|
||||
for old in [format!("{STEM}.js"), format!("{STEM}_bg.wasm"), format!("{STEM}_loader.js")] {
|
||||
std::fs::remove_file(dir.join(&old)).with_context(|| format!("removing {old}"))?;
|
||||
}
|
||||
let html_path = dir.join("index.html");
|
||||
let html = std::fs::read_to_string(&html_path).with_context(|| format!("reading {}", html_path.display()))?;
|
||||
write(&html_path, add_worker_meta(&html, &hashed.loader)?.as_bytes())?;
|
||||
println!("web-worker-link: the page loads its OCR worker from {}", hashed.loader);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write(path: &Path, bytes: &[u8]) -> Result<()> {
|
||||
std::fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))
|
||||
}
|
||||
|
||||
/// The worker's three files as Trunk wrote them.
|
||||
struct Worker {
|
||||
js: Vec<u8>,
|
||||
wasm: Vec<u8>,
|
||||
loader: Vec<u8>,
|
||||
}
|
||||
|
||||
struct Hashed {
|
||||
/// The files to write, by name.
|
||||
files: Vec<(String, Vec<u8>)>,
|
||||
/// The loader's new name.
|
||||
loader: String,
|
||||
}
|
||||
|
||||
/// Replace the one occurrence of `from` in `text` with `to`.
|
||||
fn replace_once(text: &str, from: &str, to: &str, what: &str) -> Result<String> {
|
||||
match text.matches(from).count() {
|
||||
1 => Ok(text.replacen(from, to, 1)),
|
||||
n => bail!("expected {what} to name {from} once, found {n}"),
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
fn hashed(&self) -> Result<Hashed> {
|
||||
let mut both = self.js.clone();
|
||||
both.extend_from_slice(&self.wasm);
|
||||
let hash: String = crate::assets::sha256_hex(&both).chars().take(16).collect();
|
||||
let (js, wasm, loader) = (format!("{STEM}-{hash}.js"), format!("{STEM}-{hash}_bg.wasm"), format!("{STEM}-{hash}_loader.js"));
|
||||
let glue = std::str::from_utf8(&self.js).context("the worker's script is not UTF-8")?;
|
||||
let glue = replace_once(glue, &format!("{STEM}_bg.wasm"), &wasm, "the worker's script")?;
|
||||
let shim = std::str::from_utf8(&self.loader).context("the worker's loader is not UTF-8")?;
|
||||
let shim = replace_once(shim, &format!("./{STEM}.js"), &format!("./{js}"), "the worker's loader")?;
|
||||
Ok(Hashed { files: vec![(js, glue.into_bytes()), (wasm, self.wasm.clone()), (loader.clone(), shim.into_bytes())], loader })
|
||||
}
|
||||
}
|
||||
|
||||
/// `html` with the meta tag naming `loader` (replacing an earlier one).
|
||||
fn add_worker_meta(html: &str, loader: &str) -> Result<String> {
|
||||
if loader.contains(['"', '<', '>', '&']) {
|
||||
bail!("unexpected characters in the worker loader's name: {loader}");
|
||||
}
|
||||
let tag = format!("<meta name=\"{META}\" content=\"./{loader}\" />");
|
||||
let without: String = html.lines().filter(|l| !l.contains(&format!("name=\"{META}\""))).map(|l| format!("{l}\n")).collect();
|
||||
let Some(head_end) = without.find("</head>") else {
|
||||
bail!("index.html has no </head>");
|
||||
};
|
||||
Ok(format!("{} {tag}\n {}", without[..head_end].trim_end_matches([' ', '\t']), &without[head_end..]))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn trunk_output(wasm: &[u8]) -> Worker {
|
||||
Worker {
|
||||
js: b"function f(){ module_or_path = new URL('pdfcraft-ocr-worker_bg.wasm', import.meta.url); }".to_vec(),
|
||||
wasm: wasm.to_vec(),
|
||||
loader: b"import init from './pdfcraft-ocr-worker.js';await init();".to_vec(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worker_files_get_content_hashed_names_that_point_at_each_other() {
|
||||
let h = trunk_output(b"\0asm one").hashed().unwrap();
|
||||
let names: Vec<&str> = h.files.iter().map(|f| f.0.as_str()).collect();
|
||||
let hash = &h.loader["pdfcraft-ocr-worker-".len()..h.loader.len() - "_loader.js".len()];
|
||||
assert_eq!(hash.len(), 16);
|
||||
assert_eq!(names, [format!("{STEM}-{hash}.js"), format!("{STEM}-{hash}_bg.wasm"), h.loader.clone()]);
|
||||
let text = |i: usize| String::from_utf8(h.files[i].1.clone()).unwrap();
|
||||
assert!(text(0).contains(&format!("new URL('{STEM}-{hash}_bg.wasm', import.meta.url)")), "{}", text(0));
|
||||
assert_eq!(text(2), format!("import init from './{STEM}-{hash}.js';await init();"));
|
||||
assert_eq!(h.files[1].1, b"\0asm one");
|
||||
// A different build gets a different name, so a cached old worker is never paired with it.
|
||||
assert_ne!(trunk_output(b"\0asm two").hashed().unwrap().loader, h.loader);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unexpected_worker_files_are_refused() {
|
||||
let mut w = trunk_output(b"\0asm");
|
||||
w.js = b"no wasm named here".to_vec();
|
||||
assert!(w.hashed().is_err());
|
||||
let mut w = trunk_output(b"\0asm");
|
||||
w.loader = b"import a from './pdfcraft-ocr-worker.js'; import b from './pdfcraft-ocr-worker.js';".to_vec();
|
||||
assert!(w.hashed().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_page_names_the_loader_once() {
|
||||
let html = "<html>\n <head>\n <title>PdfCraft</title>\n </head>\n <body></body>\n</html>\n";
|
||||
let once = add_worker_meta(html, "pdfcraft-ocr-worker-9f8e_loader.js").unwrap();
|
||||
assert!(once.contains("<meta name=\"pdfcraft-ocr-worker\" content=\"./pdfcraft-ocr-worker-9f8e_loader.js\" />\n </head>"), "{once}");
|
||||
// Running again replaces the tag rather than adding another.
|
||||
let twice = add_worker_meta(&once, "pdfcraft-ocr-worker-7777_loader.js").unwrap();
|
||||
assert_eq!(twice.matches("pdfcraft-ocr-worker").count(), 2, "{twice}");
|
||||
assert!(twice.contains("./pdfcraft-ocr-worker-7777_loader.js"));
|
||||
assert!(twice.contains("<title>PdfCraft</title>") && twice.ends_with("</html>\n"));
|
||||
assert!(add_worker_meta(html, "pdfcraft-ocr-worker-\"x_loader.js").is_err());
|
||||
assert!(add_worker_meta("<body></body>", "pdfcraft-ocr-worker_loader.js").is_err());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user