diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index fa3efb62a7..23ebcbbe82 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -335,8 +335,8 @@ Fonts are fetched by `cargo xtask demo-pdf` into `target/demo-fonts/`, OCR model | `NotoColorEmoji-Regular.ttf` | Noto Color Emoji | Google Inc. | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/notocoloremoji | Showcase PDF (cargo xtask demo-pdf) | | `Inter[opsz,wght].ttf` | Inter (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | | `Inter-Italic[opsz,wght].ttf` | Inter Italic (variable) | The Inter Project Authors (Rasmus Andersson) | OFL-1.1 | https://github.com/google/fonts/tree/9710da1eacb3be272583c3224dcb70f9da6eadbb/ofl/inter | Showcase PDF (cargo xtask demo-pdf) | -| `text-detection.rten` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | -| `text-recognition.rten` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | +| `text-detection.onnx` | Ocrs text detection model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: finds words on page images (cargo xtask models) | +| `text-recognition.onnx` | Ocrs text recognition model | Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0) | CC-BY-SA-4.0 | https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0) | Scan & OCR: reads the words it finds (cargo xtask models) | ## Optional build inputs (1) diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index bfaf948862..489247559d 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -3356,30 +3356,30 @@ usage = "1x + 2x HiDPI 16-colour palette TIFF copied into the macOS DMG by packa sha256 = "f46881724a6621f3686a612a79dbdce4f7dbe9e2025610e44c9a094341334c2c" [[fetched]] -file = "text-detection.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.rten" +file = "text-detection.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-detection.onnx" title = "Ocrs text detection model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: finds words on page images (cargo xtask models)" -sha256 = "f15cfb56bd02c4bf478a20343986504a1f01e1665c2b3a0ad66340f054b1b5ca" +sha256 = "a917b23dbd9524b465df7e922641b3ff2981623df4ded5a0234004ef2fee7cfe" [[fetched]] -file = "text-recognition.rten" -url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.rten" +file = "text-recognition.onnx" +url = "https://ocrs-models.s3-accelerate.amazonaws.com/text-recognition.onnx" title = "Ocrs text recognition model" author = "Robert Knight (ocrs-models; trained on HierText, CC-BY-SA-4.0)" -source = "https://github.com/robertknight/ocrs-models (the models ocrs downloads; model card: https://huggingface.co/robertknight/ocrs, cc-by-sa-4.0)" +source = "https://github.com/robertknight/ocrs/blob/0c826441362a701c55560fefc17bf356b6d1037e/ocrs/examples/download-models.sh (author-published ONNX models; model card: https://huggingface.co/robertknight/ocrs/blob/df0edd170279ab971b53e094c627255a87e1a503/README.md, cc-by-sa-4.0)" licence = "CC-BY-SA-4.0" licence_url = "https://creativecommons.org/licenses/by-sa/4.0/legalcode.txt" licence_sha256 = "28a9529c7d0bb4dc51f4bf5c116a3d16ef247a052f7591466768ddf563fd1cf5" kind = "model" usage = "Scan & OCR: reads the words it finds (cargo xtask models)" -sha256 = "e484866d4cce403175bd8d00b128feb08ab42e208de30e42cd9889d8f1735a6e" +sha256 = "86c145c2edb96c8caed5b1ebb8f44d706408922211451c309c625157dd6061c5" [[build_input]] name = "craft-fonts" diff --git a/Cargo.lock b/Cargo.lock index 3c713ba5cc..b201da8413 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1023,6 +1023,15 @@ dependencies = [ "x509-cert", ] +[[package]] +name = "cobs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" +dependencies = [ + "thiserror 2.0.21", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1169,12 +1178,40 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] [[package]] name = "craft-keymap" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "egui", "serde", @@ -1184,7 +1221,7 @@ dependencies = [ [[package]] name = "craft-menu" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-keymap", "craft-quit", @@ -1197,11 +1234,11 @@ dependencies = [ [[package]] name = "craft-print" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=42e30cd5b3f4b114270dd053fccbc33bedc47f68#42e30cd5b3f4b114270dd053fccbc33bedc47f68" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "craft-winprint-gdi", - "miniz_oxide 0.8.9", - "roxmltree", + "miniz_oxide 0.9.1", + "roxmltree 0.21.2", "tempfile", "winprint", "winsafe", @@ -1210,7 +1247,7 @@ dependencies = [ [[package]] name = "craft-quit" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "dispatch2", "egui", @@ -1221,20 +1258,28 @@ dependencies = [ [[package]] name = "craft-ui" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=edc41f68fa24c46651c3b5117d10f0ead75ec9cb#edc41f68fa24c46651c3b5117d10f0ead75ec9cb" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ + "craft-fonts-build", + "craft-i18n", "egui", + "epaint", + "js-sys", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "serde", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", ] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -2073,6 +2118,17 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2188,7 +2244,7 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" dependencies = [ - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -2882,6 +2938,23 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_decimal" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + [[package]] name = "icu_locale_core" version = "2.3.0" @@ -2890,11 +2963,32 @@ checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + [[package]] name = "icu_normalizer" version = "2.3.0" @@ -2907,6 +3001,7 @@ dependencies = [ "icu_provider", "smallvec", "utf16_iter", + "utf8_iter", "write16", "zerovec", ] @@ -2917,6 +3012,32 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" +[[package]] +name = "icu_pattern" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b3db611446bd92c246b03e4aa0bafa71ddb194af431ddbb42b9d70e4bfce1a0" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] + [[package]] name = "icu_properties" version = "2.3.0" @@ -2946,6 +3067,9 @@ checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2953,6 +3077,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -3251,6 +3399,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -4340,6 +4491,7 @@ dependencies = [ "ocrs", "pdfcraft-fonts", "rten", + "tempfile", "thiserror 2.0.21", ] @@ -4391,7 +4543,7 @@ dependencies = [ "pdfcraft-content", "pdfcraft-cos", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", ] [[package]] @@ -4554,7 +4706,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-fonts", "png 0.18.1", - "roxmltree", + "roxmltree 0.20.0", "sha2 0.11.1", "thiserror 2.0.21", ] @@ -4567,7 +4719,7 @@ dependencies = [ "pdfcraft-cos", "pdfcraft-forms", "pdfcraft-model", - "roxmltree", + "roxmltree 0.20.0", "thiserror 2.0.21", ] @@ -4888,12 +5040,24 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "postcard" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" +dependencies = [ + "cobs", + "serde", +] + [[package]] name = "potential_utf" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -5444,6 +5608,15 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + [[package]] name = "rsa" version = "0.10.0-rc.18" @@ -5473,6 +5646,7 @@ dependencies = [ "rten-base", "rten-gemm", "rten-model-file", + "rten-onnx", "rten-parallel", "rten-shape-inference", "rten-simd", @@ -5522,6 +5696,12 @@ dependencies = [ "rten-base", ] +[[package]] +name = "rten-onnx" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96944374568990b8d1bbc5bae5957585866f9dd8256130460405730948bfd42e" + [[package]] name = "rten-parallel" version = "0.26.0" @@ -6515,7 +6695,10 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" dependencies = [ + "js-sys", "libc", + "wasm-bindgen", + "web-sys", ] [[package]] @@ -6710,6 +6893,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -7078,7 +7262,7 @@ dependencies = [ "kurbo 0.11.3", "log", "pico-args", - "roxmltree", + "roxmltree 0.20.0", "simplecss", "siphasher", "strict-num", @@ -8052,6 +8236,9 @@ name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -8376,8 +8563,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -8386,6 +8576,7 @@ version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index 8eb38b29af..99f9fa251b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,17 +13,17 @@ repository = "https://github.com/storytold/pdfcraft" [workspace.dependencies] # Shared frontend components; engines remain independent of egui. -craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", features = ["serde"] } +craft-ui = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] } # Quit the macOS way (#1136): ⌘Q asks about unsaved changes, and closing quits through `terminate:`. -craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } +craft-quit = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # One menu definition for the native macOS menu bar and the in-window bar elsewhere. -craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb" } -craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "edc41f68fa24c46651c3b5117d10f0ead75ec9cb", default-features = false } +craft-menu = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-keymap = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", default-features = false } # Shared system spooler; document composition remains in pdfcraft-print. -craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "42e30cd5b3f4b114270dd053fccbc33bedc47f68" } +craft-print = { git = "https://github.com/storytold/craft-libs", version = "0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # 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 = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } # internal pdfcraft-geom = { path = "crates/geom", version = "0.14.0" } pdfcraft-filters = { path = "crates/filters", version = "0.14.0" } diff --git a/ROADMAP.md b/ROADMAP.md index 165fccc5e8..f5de8044e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,6 +2,7 @@ **Stage: alpha** · next: beta, ~25% (ready for real work 50% → 75%) and ~400–700 h away +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -199,6 +200,7 @@ Ranked; detail and the full milestone table in [docs/roadmap.md](docs/roadmap.md ## Progress log +- **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-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-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. @@ -682,6 +684,7 @@ Newest first: the date, what moved, and the new overall estimate. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M11: image downsampling measures physical resolution after page-local UserUnit scaling, including shared nested forms; synthetic measurement and optimization regressions. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/clippy.toml b/clippy.toml index 7f2fd53057..e908d13581 100644 --- a/clippy.toml +++ b/clippy.toml @@ -13,3 +13,7 @@ disallowed-methods = [ { path = "egui::Ui::radio_value", reason = "use radio_value from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, { path = "egui::RadioButton::new", reason = "use radio from crates/ui-egui/src/widgets.rs, which is outlined and filled" }, ] + +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/automation/src/documents.rs b/crates/automation/src/documents.rs index 26addfd80b..3ce312f395 100644 --- a/crates/automation/src/documents.rs +++ b/crates/automation/src/documents.rs @@ -539,11 +539,23 @@ impl Automation { }; let mut files = Vec::new(); let mut lowered = Vec::new(); - for p in pages { - let img = ex.image(p, dpi, format).map_err(failed)?; + // A write error stops the export as it is, not as a page error. + let mut write_error = None; + let exported = ex.images(&pages, dpi, format, |p, img| { let path = child(&folder, &format!("{stem}_page_{}.{}", p + 1, format.extension())); - write_atomic(&path, &img)?; + write_atomic(&path, &img).map_err(|e| { + let message = e.to_string(); + write_error = Some(e); + message + })?; files.push(path.to_string_lossy().into_owned()); + Ok(()) + }); + if let Some(e) = write_error { + return Err(e); + } + exported.map_err(failed)?; + for &p in &pages { // A page too large for the renderer at `dpi` is drawn at the most it allows. let used = ex.dpi_used(p, dpi); if used < dpi.clamp(18.0, 1200.0) - 0.5 { diff --git a/crates/engine/examples/export_bench.rs b/crates/engine/examples/export_bench.rs new file mode 100644 index 0000000000..41520d2ffa --- /dev/null +++ b/crates/engine/examples/export_bench.rs @@ -0,0 +1,61 @@ +//! Times Export ▸ Image over every page of a PDF, for before and after comparisons. +//! +//! Usage: `export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]` (defaults: 150 dpi, PNG, 7 runs). Each +//! run exports every page through [`Exporter::images`] from a fresh exporter, as Export ▸ Image +//! does, and prints its wall time; the summary gives the median and the minimum, the total size +//! and an FNV-1a hash of every file in page order. Equal hashes mean equal output. Build with +//! `--release`; timings are diagnostic, never a CI deadline. + +use std::error::Error; +use std::sync::Arc; +use std::time::Instant; + +use pdfcraft_engine::Session; +use pdfcraft_engine::export::{Exporter, ImageFormat}; + +const FNV_OFFSET: u64 = 0xcbf2_9ce4_8422_2325; +const FNV_PRIME: u64 = 0x0000_0100_0000_01b3; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let path = args.next().ok_or("usage: export_bench FILE [DPI] [png|jpeg|tiff] [RUNS]")?; + let dpi: f64 = args.next().map_or(Ok(150.0), |s| s.parse())?; + let format = match args.next().as_deref() { + None | Some("png") => ImageFormat::Png, + Some("jpeg" | "jpg") => ImageFormat::Jpeg { quality: 85 }, + Some("tiff" | "tif") => ImageFormat::Tiff, + Some(other) => return Err(format!("unknown format {other:?}").into()), + }; + let runs: usize = args.next().map_or(Ok(7), |s| s.parse())?; + let mut session = Session::new(); + let id = session.open("bench.pdf", None, Arc::new(std::fs::read(&path)?), None)?; + let src = session.get(id).ok_or("the document did not open")?.export_source(); + let pages: Vec = (0..src.pages).collect(); + let mut times = Vec::new(); + let mut summary = None; + for run in 0..runs { + let start = Instant::now(); + let mut exporter = Exporter::from_source(src.clone()); + let (mut hash, mut bytes) = (FNV_OFFSET, 0usize); + exporter.images(&pages, dpi, format, |_, file| { + bytes += file.len(); + for b in file { + hash = (hash ^ u64::from(b)).wrapping_mul(FNV_PRIME); + } + Ok(()) + })?; + let ms = start.elapsed().as_secs_f64() * 1000.0; + println!("run {}: {ms:.1} ms", run + 1); + times.push(ms); + summary = Some((bytes, hash)); + } + times.sort_by(f64::total_cmp); + if let (Some(median), Some(min), Some((bytes, hash))) = (times.get(times.len() / 2), times.first(), summary) { + println!( + "{} pages, {} at {dpi} dpi: median {median:.1} ms, min {min:.1} ms ({runs} runs), {bytes} bytes, hash {hash:016x}", + pages.len(), + format.label() + ); + } + Ok(()) +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 01159df800..1330faf272 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -4,13 +4,52 @@ //! text, from a //! document's working file (so unsaved edits and hidden layers are respected, as on screen). +use std::collections::BTreeMap; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Condvar, Mutex, PoisonError, mpsc}; + use pdfcraft_render::{PageRenderer, RenderRequest, RequestKind}; use crate::Document; +/// At most this many pages are drawn at once by [`Exporter::images`]. +const MAX_IMAGE_WORKERS: usize = 8; +/// Bytes of rasters [`Exporter::images`] may hold at once: 1 GiB, about 16 Letter pages at 300 dpi. +/// Each worker holds one page (the raster, its RGB copy and the encoded file), so large pages run +/// on fewer workers; a page over the cap still exports, on one worker, as it did before. +const IMAGE_MEMORY: f64 = 1024.0 * 1024.0 * 1024.0; + +/// Page `page` drawn at `dpi` and encoded as `format`, saying it was drawn at `used` dpi. +fn draw_image(renderer: &mut PageRenderer, page: usize, dpi: f64, format: ImageFormat, used: f64) -> Result, String> { + let r = renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); + if let Some(e) = r.error { + return Err(format!("page {}: {e}", page + 1)); + } + encode_image(r.width, r.height, &r.rgba, format, used) +} + +/// Tells the workers of [`Exporter::images`] to stop when dropped. +struct StopWorkers<'a> { + stop: &'a AtomicBool, + handed_over: &'a (Mutex, Condvar), +} + +impl Drop for StopWorkers<'_> { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + // Taken under the lock, so a worker between its check and its wait still sees it. + drop(self.handed_over.0.lock().unwrap_or_else(PoisonError::into_inner)); + self.handed_over.1.notify_all(); + } +} + /// Renders and extracts pages of one document state. pub struct Exporter { renderer: PageRenderer, + /// What the renderer draws, for the extra renderers [`Exporter::images`] starts. + bytes: std::sync::Arc>, + config: pdfcraft_render::RenderConfig, pages: usize, /// Each page's displayed size in points, for [`Exporter::dpi_used`]. sizes: Vec<(f32, f32)>, @@ -65,7 +104,13 @@ impl Exporter { /// resolution they allow, as the interactive Export does; [`Exporter::dpi_used`] says at what. /// A source whose `config.reject_oversize` is set refuses such pages instead. pub fn from_source(src: ExportSource) -> Self { - Self { renderer: PageRenderer::new(src.bytes, src.config), pages: src.pages, sizes: src.sizes } + Self { + renderer: PageRenderer::new(src.bytes.clone(), src.config.clone()), + bytes: src.bytes, + config: src.config, + pages: src.pages, + sizes: src.sizes, + } } /// The resolution page `page` is actually exported at when asked for `dpi`: lower than asked @@ -96,11 +141,146 @@ impl Exporter { /// Page `page` as an image file of `format`, which says the resolution it was drawn at. pub fn image(&mut self, page: usize, dpi: f64, format: ImageFormat) -> Result, String> { self.check(page)?; - let r = self.renderer.render(RenderRequest { page, scale: (dpi.clamp(18.0, 1200.0) / 72.0) as f32, ..Default::default() }); - if let Some(e) = r.error { - return Err(format!("page {}: {e}", page + 1)); + let used = self.dpi_used(page, dpi); + draw_image(&mut self.renderer, page, dpi, format, used) + } + + /// Pages `pages` as image files of `format`, handed to `sink` in order with their page + /// number: the same files as [`Exporter::image`] for each page in turn. Encoding a page costs + /// several times what drawing it does, so several pages are drawn and encoded at once on + /// worker threads, each with its own renderer, while `sink` runs on the calling thread. + /// Workers draw at most one page each ahead of `sink`, and fewer run when the pages are + /// large, so memory stays bounded. Stops at the first page that fails or the first error `sink` + /// returns, and returns it; every page before it has been handed to `sink`. + pub fn images( + &mut self, + pages: &[usize], + dpi: f64, + format: ImageFormat, + sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + let workers = self.image_workers(pages, dpi); + self.images_on(workers, pages, dpi, format, sink) + } + + /// [`Exporter::images`] with `workers` pages drawn at once (one: in turn, on this thread). + pub(crate) fn images_on( + &mut self, + workers: usize, + pages: &[usize], + dpi: f64, + format: ImageFormat, + mut sink: impl FnMut(usize, Vec) -> Result<(), String>, + ) -> Result<(), String> { + if workers <= 1 { + for &p in pages { + let img = self.image(p, dpi, format)?; + sink(p, img)?; + } + return Ok(()); } - encode_image(r.width, r.height, &r.rgba, format, self.dpi_used(page, dpi)) + let used: Vec = pages.iter().map(|p| self.dpi_used(*p, dpi)).collect(); + let count = self.pages; + let (bytes, config) = (&self.bytes, &self.config); + let next = AtomicUsize::new(0); + let stop = AtomicBool::new(false); + // How many pages `sink` has taken; a worker waits while its page is `workers` past it. + let handed_over = (Mutex::new(0usize), Condvar::new()); + let (tx, rx) = mpsc::channel::<(usize, Result, String>)>(); + let mut first = Some(&mut self.renderer); + std::thread::scope(|s| { + let handles: Vec<_> = (0..workers) + .map(|_| { + let tx = tx.clone(); + let (first, used, next, stop, handed_over) = (first.take(), &used, &next, &stop, &handed_over); + s.spawn(move || { + let mut own = None; + let renderer = match first { + Some(r) => r, + None => own.insert(PageRenderer::new(bytes.clone(), config.clone())), + }; + loop { + let i = next.fetch_add(1, Ordering::Relaxed); + let (Some(&page), Some(&used)) = (pages.get(i), used.get(i)) else { break }; + let mut taken = handed_over.0.lock().unwrap_or_else(PoisonError::into_inner); + while i >= taken.saturating_add(workers) && !stop.load(Ordering::Relaxed) { + taken = handed_over.1.wait(taken).unwrap_or_else(PoisonError::into_inner); + } + drop(taken); + if stop.load(Ordering::Relaxed) { + break; + } + let img = if page < count { + catch_unwind(AssertUnwindSafe(|| draw_image(renderer, page, dpi, format, used))) + .unwrap_or_else(|_| Err(format!("page {}: the export crashed", page + 1))) + } else { + Err(format!("page {} does not exist", page + 1)) + }; + if tx.send((i, img)).is_err() { + break; + } + } + }) + }) + .collect(); + drop(tx); + // Stops the workers however this ends, a panicking `sink` included: waiting ones + // would otherwise keep the scope from returning. + let stopper = StopWorkers { stop: &stop, handed_over: &handed_over }; + let mut ready = BTreeMap::new(); + let mut result = Ok(()); + let mut taken = 0; + while let Some(&page) = pages.get(taken) { + let Some(img) = ready.remove(&taken) else { + match rx.recv() { + Ok((i, img)) => { + ready.insert(i, img); + } + Err(_) => { + result = Err(format!("page {}: the export stopped unexpectedly", page + 1)); + break; + } + } + continue; + }; + if let Err(e) = img.and_then(|img| sink(page, img)) { + result = Err(e); + break; + } + taken += 1; + *handed_over.0.lock().unwrap_or_else(PoisonError::into_inner) = taken; + handed_over.1.notify_all(); + } + drop(stopper); + // Joined here, so a worker that panicked outside a page is not re-raised by the scope. + for h in handles { + let _ = h.join(); + } + result + }) + } + + /// How many pages [`Exporter::images`] draws at once: one per core but one, up to + /// [`MAX_IMAGE_WORKERS`], as many as [`IMAGE_MEMORY`] holds of the largest page, and one + /// when there is nothing to share or no threads (wasm). + fn image_workers(&self, pages: &[usize], dpi: f64) -> usize { + if cfg!(target_arch = "wasm32") || pages.len() < 2 { + return 1; + } + // One core is left for the interface, which keeps drawing while an export runs. + let cores = std::thread::available_parallelism().map_or(1, std::num::NonZeroUsize::get).saturating_sub(1).min(MAX_IMAGE_WORKERS); + // The raster (RGBA), the RGB copy the encoders take, and room for the encoded file. + let largest = pages + .iter() + .filter_map(|p| { + let (w, h) = *self.sizes.get(*p)?; + let k = self.dpi_used(*p, dpi) / 72.0; + Some((f64::from(w) * k).ceil() * (f64::from(h) * k).ceil() * 8.0) + }) + .fold(0.0, f64::max); + // A float-to-integer cast saturates (and NaN is 0). + let by_memory = if largest > 0.0 { (IMAGE_MEMORY / largest) as usize } else { cores }; + cores.min(by_memory).min(pages.len()).max(1) } /// The reading-order text of a page. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index c622af21b4..e7e68a7588 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3410,6 +3410,70 @@ fn pages_export_as_png_jpeg_and_tiff() { assert_eq!((compression, predictor), (5, 1), "LZW, without a predictor"); } +/// Export ▸ Image draws several pages at once: each file is the one exporting that page alone +/// writes, handed over in the order asked for, whatever the number of pages drawn at once. +#[test] +fn pages_exported_at_once_are_the_files_each_page_exports_alone_in_order() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(12); + let pages = [3, 0, 11, 5, 5, 7, 1, 2, 10, 9, 8, 6, 4]; + for format in [ImageFormat::Png, ImageFormat::Jpeg { quality: 85 }, ImageFormat::Tiff] { + let alone: Vec<(usize, Vec)> = pages.iter().map(|&p| (p, Exporter::new(s.get(id).unwrap()).image(p, 96.0, format).unwrap())).collect(); + for workers in [1, 2, 4, 8] { + let mut got = Vec::new(); + let mut ex = Exporter::new(s.get(id).unwrap()); + ex.images_on(workers, &pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{} with {workers} workers", format.label()); + } + let mut got = Vec::new(); + Exporter::new(s.get(id).unwrap()) + .images(&pages, 96.0, format, |p, img| { + got.push((p, img)); + Ok(()) + }) + .unwrap(); + assert!(got == alone, "{}", format.label()); + } +} + +/// A page that can't be exported, or a file that can't be written, stops the export there with +/// its error: every page before it is handed over, none after it. +#[test] +fn exporting_pages_at_once_stops_at_the_first_error() { + use crate::export::{Exporter, ImageFormat}; + let (s, id) = session_with(6); + for workers in [1, 4] { + let mut ex = Exporter::new(s.get(id).unwrap()); + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 99, 2, 3], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + Ok(()) + }); + assert_eq!(result, Err("page 100 does not exist".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1], "{workers} workers"); + + let mut got = Vec::new(); + let result = ex.images_on(workers, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| { + got.push(p); + if p == 2 { Err("disk full".to_string()) } else { Ok(()) } + }); + assert_eq!(result, Err("disk full".to_string()), "{workers} workers"); + assert_eq!(got, [0, 1, 2], "{workers} workers"); + // The exporter is still usable afterwards. + assert!(ex.image(5, 72.0, ImageFormat::Png).is_ok()); + } + // A panicking sink stops the workers rather than leaving them waiting for it. + let mut ex = Exporter::new(s.get(id).unwrap()); + let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + ex.images_on(4, &[0, 1, 2, 3, 4, 5], 72.0, ImageFormat::Png, |p, _| if p == 1 { panic!("sink") } else { Ok(()) }) + })); + assert!(panicked.is_err()); +} + /// A TIFF's resolution unit (2 = inch) and its horizontal and vertical resolution. fn tiff_resolution(bytes: &[u8]) -> (u32, f64, f64) { use tiff::{decoder::ifd::Value, tags::Tag}; diff --git a/crates/ocr/Cargo.toml b/crates/ocr/Cargo.toml index 46bb3a1e0e..750e78b96d 100644 --- a/crates/ocr/Cargo.toml +++ b/crates/ocr/Cargo.toml @@ -13,5 +13,8 @@ workspace = true [dependencies] pdfcraft-fonts = { workspace = true } ocrs = { version = "0.13.1", default-features = false, features = ["rten"] } -rten = { version = "0.26.0", default-features = false, features = ["rten_format"] } +rten = { version = "0.26.0", default-features = false, features = ["onnx_format", "rten_format"] } thiserror.workspace = true + +[dev-dependencies] +tempfile = "3.27" diff --git a/crates/ocr/src/lib.rs b/crates/ocr/src/lib.rs index affa4ab163..7f58270e1a 100644 --- a/crates/ocr/src/lib.rs +++ b/crates/ocr/src/lib.rs @@ -15,8 +15,8 @@ use std::path::{Path, PathBuf}; pub use pdfcraft_fonts::helvetica_width; /// The model files, as named in ATTRIBUTION.toml. -pub const DETECTION_MODEL: &str = "text-detection.rten"; -pub const RECOGNITION_MODEL: &str = "text-recognition.rten"; +pub const DETECTION_MODEL: &str = "text-detection.onnx"; +pub const RECOGNITION_MODEL: &str = "text-recognition.onnx"; /// The recognition model's alphabet, as it was trained (ocrs-models' `DEFAULT_ALPHABET`): its /// output classes follow this order. The ocrs crate's own copy has an `E` where the training had @@ -48,10 +48,13 @@ pub struct Models { } impl Models { - /// The models in `dir`, if both files are there. + /// A complete model pair in `dir`, preferring current ONNX files over legacy RTEN files. + /// An incomplete update never mixes formats or hides an intact legacy pair. pub fn in_dir(dir: &Path) -> Option { - let m = Models { detection: dir.join(DETECTION_MODEL), recognition: dir.join(RECOGNITION_MODEL) }; - (m.detection.is_file() && m.recognition.is_file()).then_some(m) + [(DETECTION_MODEL, RECOGNITION_MODEL), ("text-detection.rten", "text-recognition.rten")].into_iter().find_map(|(detection, recognition)| { + let m = Models { detection: dir.join(detection), recognition: dir.join(recognition) }; + (m.detection.is_file() && m.recognition.is_file()).then_some(m) + }) } /// Look for the models: `$PDFCRAFT_MODELS`, then where the release packages install them @@ -321,6 +324,63 @@ mod tests { assert_eq!(w.up, [-10.0, 0.0]); } + /// The current ONNX pair wins when an installation also retains both legacy models. + #[test] + fn model_discovery_prefers_the_complete_onnx_pair() { + let dir = tempfile::tempdir().unwrap(); + for file in ["text-detection.onnx", "text-recognition.onnx"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + let expected = Some(Models { detection: dir.path().join("text-detection.onnx"), recognition: dir.path().join("text-recognition.onnx") }); + assert_eq!(Models::in_dir(dir.path()), expected, "a complete ONNX pair is sufficient"); + for file in ["text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), expected, "the complete ONNX pair takes precedence over legacy models"); + } + + /// An interrupted ONNX update still permits an intact legacy pair in the same directory. + #[test] + fn model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete() { + for current in ["text-detection.onnx", "text-recognition.onnx"] { + let dir = tempfile::tempdir().unwrap(); + for file in [current, "text-detection.rten", "text-recognition.rten"] { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }), + "{current} alone must not hide a complete legacy pair" + ); + } + } + + /// Detection and recognition must come from one complete format pair, never a mixed update. + #[test] + fn model_discovery_refuses_mixed_format_pairs() { + for pair in [["text-detection.onnx", "text-recognition.rten"], ["text-detection.rten", "text-recognition.onnx"]] { + let dir = tempfile::tempdir().unwrap(); + for file in pair { + std::fs::write(dir.path().join(file), b"model").unwrap(); + } + assert_eq!(Models::in_dir(dir.path()), None, "{pair:?} is not a complete model pair"); + } + } + + /// Existing source builds and PDFCRAFT_MODELS overrides retain their complete RTEN pair. + #[test] + fn model_discovery_keeps_legacy_only_installations() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(Models::in_dir(dir.path()), None); + std::fs::write(dir.path().join("text-detection.rten"), b"model").unwrap(); + assert_eq!(Models::in_dir(dir.path()), None, "one legacy model is incomplete"); + std::fs::write(dir.path().join("text-recognition.rten"), b"model").unwrap(); + assert_eq!( + Models::in_dir(dir.path()), + Some(Models { detection: dir.path().join("text-detection.rten"), recognition: dir.path().join("text-recognition.rten") }) + ); + } + /// #103: every release package's layout puts the models where the installed app looks. /// Mirrors packaging/{windows/package.ps1,macos/package.sh,linux/package.sh,freebsd/package.sh}. #[test] diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d0e9482d06..a0c1ca880e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -225,7 +225,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.end_row(); let label = ui.label(tl!("Navigation tab")); ui.add_enabled_ui(editable, |ui| { - egui::ComboBox::from_id_salt("iv-nav") + craft_ui::ComboBox::from_id_salt("iv-nav") .selected_text(tl!(format!("{:?}", v.navigation) .replace("PageOnly", "Page Only") .replace("Pages", "Pages Panel and Page") @@ -257,7 +257,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::TwoUpContinuousCoverPage, tl!("Two-Up Continuous (Cover Page)")), ]; let shown = names.iter().find(|(l, _)| *l == v.layout).map_or(tl!("Default"), |(_, n)| n); - egui::ComboBox::from_id_salt("iv-layout") + craft_ui::ComboBox::from_id_salt("iv-layout") .selected_text(shown) .show_ui(ui, |ui| { for (l, n) in names { @@ -283,7 +283,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { M::Percent(p) => format!("{p:.0}%"), m => names.iter().find(|(x, _)| *x == m).map_or(tl!("Default").to_string(), |(_, n)| n.to_string()), }; - egui::ComboBox::from_id_salt("iv-mag") + craft_ui::ComboBox::from_id_salt("iv-mag") .selected_text(shown) .show_ui(ui, |ui| { for (m, n) in names { @@ -798,7 +798,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(8.0); egui::Grid::new("rotate-pages").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Direction:")); - egui::ComboBox::from_id_salt("rotate-dir") + craft_ui::ComboBox::from_id_salt("rotate-dir") .selected_text(match d.degrees { 270 => tl!("Counterclockwise 90 degrees"), 180 => tl!("180 degrees"), @@ -824,7 +824,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); ui.label(tl!("Rotate:")); - egui::ComboBox::from_id_salt("rotate-parity") + craft_ui::ComboBox::from_id_salt("rotate-parity") .selected_text(match d.parity { P::Both => tl!("Even and Odd Pages"), P::Even => tl!("Even Pages Only"), @@ -837,7 +837,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { }); ui.end_row(); ui.label(""); - egui::ComboBox::from_id_salt("rotate-orient") + craft_ui::ComboBox::from_id_salt("rotate-orient") .selected_text(match d.orientation { O::Both => tl!("Landscape and Portrait Pages"), O::Landscape => tl!("Landscape Pages"), @@ -946,7 +946,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { ui.add_space(6.0); ui.horizontal(|ui| { ui.label(tl!("Sort comments by:")); - egui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("summary-sort").selected_text(tl!(app.summary_sort.name())).show_ui(ui, |ui| { for s in pdfcraft_engine::SummarySort::ALL { ui.selectable_value(&mut app.summary_sort, s, tl!(s.name())); } @@ -1222,7 +1222,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { (L::None, tl!("None (prefix only)")), ]; let current = styles.iter().find(|(s, _)| *s == d.style).map_or("1, 2, 3", |(_, l)| *l); - egui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("label_style").selected_text(current).show_ui(ui, |ui| { for (s, l) in styles { ui.selectable_value(&mut d.style, s, l); } diff --git a/crates/ui-egui/src/dialogs/appearance.rs b/crates/ui-egui/src/dialogs/appearance.rs index 1393c9d937..48ad0cb31c 100644 --- a/crates/ui-egui/src/dialogs/appearance.rs +++ b/crates/ui-egui/src/dialogs/appearance.rs @@ -119,7 +119,7 @@ pub fn preferences_section(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.label(RichText::new(tl_ctx!("theme", "Appearance")).font(theme::semibold(13.0))); ui.horizontal(|ui| { let label = ui.label(tl!("Appearance mode")); - let combo = egui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { + let combo = craft_ui::ComboBox::from_id_salt("appearance-mode").selected_text(tl!(mode_label(app.theme_preference))).show_ui(ui, |ui| { for preference in [ThemePreference::System, ThemePreference::Light, ThemePreference::Dark] { if ui.selectable_label(app.theme_preference == preference, tl!(mode_label(preference))).clicked() { app.execute(mode_command(preference)); diff --git a/crates/ui-egui/src/dialogs/comment_props.rs b/crates/ui-egui/src/dialogs/comment_props.rs index 38fd2c4117..4702e63f9e 100644 --- a/crates/ui-egui/src/dialogs/comment_props.rs +++ b/crates/ui-egui/src/dialogs/comment_props.rs @@ -207,7 +207,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo egui::Grid::new("comment-props").num_columns(2).spacing([12.0, 10.0]).show(ui, |ui| { if let Some(icon) = e.icon.as_mut() { ui.label(tl!("Icon")); - egui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("note-icon").selected_text(icon.name()).show_ui(ui, |ui| { for i in ICONS { ui.selectable_value(icon, i, i.name()); } @@ -247,7 +247,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.label(tl!("Line ending")); ui.horizontal(|ui| { for (i, ending) in ends.iter_mut().enumerate() { - egui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending_label(*ending)).width(188.0).show_ui( + craft_ui::ComboBox::from_id_salt(("line-ending", i)).selected_text(ending_label(*ending)).width(188.0).show_ui( ui, |ui| { for style in LineEnding::ALL { diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 4fe58a6714..d509a3b0eb 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -243,7 +243,7 @@ pub fn contributors_ui(ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::t("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::t(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::t(k.label().0)).clicked() { v.key = k; diff --git a/crates/ui-egui/src/dialogs/marks_ui.rs b/crates/ui-egui/src/dialogs/marks_ui.rs index 5340cef393..437d45e9fe 100644 --- a/crates/ui-egui/src/dialogs/marks_ui.rs +++ b/crates/ui-egui/src/dialogs/marks_ui.rs @@ -63,7 +63,7 @@ impl PageRange { Subset::Even => tl!("Even pages only"), Subset::Odd => tl!("Odd pages only"), }; - egui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("mark-subset").selected_text(label).show_ui(ui, |ui| { ui.selectable_value(&mut self.subset, Subset::All, tl!("All pages in range")); ui.selectable_value(&mut self.subset, Subset::Even, tl!("Even pages only")); ui.selectable_value(&mut self.subset, Subset::Odd, tl!("Odd pages only")); @@ -227,7 +227,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M ui.horizontal(|ui| { // The standard faces a header can be drawn in (#3754). ui.label(tl!("Font:")); - egui::ComboBox::from_id_salt("hf-font").selected_text(d.hf.font.label()).width(100.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("hf-font").selected_text(d.hf.font.label()).width(100.0).show_ui(ui, |ui| { for f in [FontFamily::Helvetica, FontFamily::Times, FontFamily::Courier] { ui.selectable_value(&mut d.hf.font, f, f.label()); } @@ -267,7 +267,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M } }); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("hf-page-format").selected_text(PAGE_FORMATS[d.page_format].trim_matches(['<', '>'])).show_ui( + craft_ui::ComboBox::from_id_salt("hf-page-format").selected_text(PAGE_FORMATS[d.page_format].trim_matches(['<', '>'])).show_ui( ui, |ui| { for (k, f) in PAGE_FORMATS.iter().enumerate() { @@ -279,7 +279,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: M d.hf.text[d.focused_box].push_str(PAGE_FORMATS[d.page_format]); } ui.add_space(12.0); - egui::ComboBox::from_id_salt("hf-date-format").selected_text(DATE_FORMATS[d.date_format].trim_matches(['<', '>'])).show_ui( + craft_ui::ComboBox::from_id_salt("hf-date-format").selected_text(DATE_FORMATS[d.date_format].trim_matches(['<', '>'])).show_ui( ui, |ui| { for (k, f) in DATE_FORMATS.iter().enumerate() { diff --git a/crates/ui-egui/src/dialogs/pageboxes.rs b/crates/ui-egui/src/dialogs/pageboxes.rs index 1522d00034..57b5edc911 100644 --- a/crates/ui-egui/src/dialogs/pageboxes.rs +++ b/crates/ui-egui/src/dialogs/pageboxes.rs @@ -102,14 +102,14 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (PageBox::Media, "MediaBox"), ]; let cur = names.iter().find(|(b, _)| *b == d.which).map_or("CropBox", |(_, l)| *l); - egui::ComboBox::from_id_salt("boxes-which").selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("boxes-which").selected_text(cur).show_ui(ui, |ui| { for (b, l) in names { ui.selectable_value(&mut d.which, b, l); } }); ui.end_row(); ui.label(tl!("Units")); - egui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("boxes-unit").selected_text(d.unit.label()).show_ui(ui, |ui| { for u in Unit::ALL { ui.selectable_value(&mut d.unit, u, u.label()); } diff --git a/crates/ui-egui/src/dialogs/protect.rs b/crates/ui-egui/src/dialogs/protect.rs index 959877de64..ffc8b53f8e 100644 --- a/crates/ui-egui/src/dialogs/protect.rs +++ b/crates/ui-egui/src/dialogs/protect.rs @@ -144,7 +144,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (A::Rc4_40, tl!("Acrobat 3.0 and later (40-bit RC4)")), ]; let cur = algos.iter().find(|(a, _)| *a == p.algorithm).map_or(algos[0].1, |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-algo").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (a, l) in algos { ui.selectable_value(&mut p.algorithm, a, l); } @@ -164,7 +164,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo let opts = [(Printing::None, tl!("None")), (Printing::Low, tl!("Low Resolution (150 dpi)")), (Printing::High, tl!("High Resolution"))]; let cur = opts.iter().find(|(o, _)| *o == p.printing).map_or(tl!("High Resolution"), |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-print").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.printing, o, l); } @@ -179,7 +179,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo (Changes::AnyExceptExtract, tl!("Any except extracting pages")), ]; let cur = opts.iter().find(|(o, _)| *o == p.changes).map_or(tl!("None"), |(_, l)| *l); - egui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("protect-changes").width(300.0).selected_text(cur).show_ui(ui, |ui| { for (o, l) in opts { ui.selectable_value(&mut p.changes, o, l); } diff --git a/crates/ui-egui/src/dialogs/standards_ui.rs b/crates/ui-egui/src/dialogs/standards_ui.rs index 5fc4b3d355..11c0d1fd50 100644 --- a/crates/ui-egui/src/dialogs/standards_ui.rs +++ b/crates/ui-egui/src/dialogs/standards_ui.rs @@ -72,7 +72,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool let before = app.pdfa.level; ui.horizontal(|ui| { ui.label(tl!("Conformance level:")); - egui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("pdfa-level").selected_text(app.pdfa.level.label()).show_ui(ui, |ui| { for l in [Level::A2b, Level::A3b] { ui.selectable_value(&mut app.pdfa.level, l, l.label()); } diff --git a/crates/ui-egui/src/panels/format_text_ui.rs b/crates/ui-egui/src/panels/format_text_ui.rs index 69a49704a2..6cf07ce05d 100644 --- a/crates/ui-egui/src/panels/format_text_ui.rs +++ b/crates/ui-egui/src/panels/format_text_ui.rs @@ -78,7 +78,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // The font, across the panel. let width = ui.available_width(); - egui::ComboBox::from_id_salt("format-font") + craft_ui::ComboBox::from_id_salt("format-font") .selected_text(fonts.shown.as_str()) .width(width) .show_ui(ui, |ui| { @@ -128,7 +128,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: // Size and colour | bullets, numbering and alignment. row(ui, |ui| { - let _ = egui::ComboBox::from_id_salt("format-size") + let _ = craft_ui::ComboBox::from_id_salt("format-size") .selected_text(number(look.size)) .width(64.0) .show_ui(ui, |ui| { @@ -206,7 +206,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: labelled_icon(ui, t, "arrow-up-down", tl!("Line spacing")); let own = tl!("Auto").to_string(); let shown = if e.line_spacing == 0.0 { own.clone() } else { number(e.line_spacing) }; - egui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("format-line-spacing").selected_text(shown).width(half).show_ui(ui, |ui| { ui.selectable_value(&mut e.line_spacing, 0.0, own.as_str()); for v in LINE_SPACINGS { ui.selectable_value(&mut e.line_spacing, v, number(v)); @@ -216,7 +216,7 @@ pub(crate) fn panel(ui: &mut egui::Ui, t: &Tokens, look: &mut AddedText, extras: ui.add_enabled_ui(false, |ui| { labelled_icon(ui, t, "pilcrow", tl!("Paragraph spacing")); let tip = format!("{} — {}", tl!("Paragraph spacing"), tl!("Planned")); - let _ = egui::ComboBox::from_id_salt("format-paragraph-spacing") + let _ = craft_ui::ComboBox::from_id_salt("format-paragraph-spacing") .selected_text("0") .width(half) .show_ui(ui, |_| {}) diff --git a/crates/ui-egui/src/tools/bulk_fields.rs b/crates/ui-egui/src/tools/bulk_fields.rs index fe401f0574..7acb2ff75b 100644 --- a/crates/ui-egui/src/tools/bulk_fields.rs +++ b/crates/ui-egui/src/tools/bulk_fields.rs @@ -238,7 +238,7 @@ impl crate::PdfCraftApp { fn bool_row(ui: &mut egui::Ui, label: &str, c: &mut Change) { row(ui, label, c, |ui, value| { - egui::ComboBox::from_id_salt(label).selected_text(tl!(if *value { "Yes" } else { "No" })).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(label).selected_text(tl!(if *value { "Yes" } else { "No" })).show_ui(ui, |ui| { ui.selectable_value(value, true, tl!("Yes")); ui.selectable_value(value, false, tl!("No")); }); @@ -296,7 +296,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool }); let font_label = if d.font.apply { d.font.value.label().to_string() } else { d.font_label.clone() }; row(ui, "Font:", &mut d.font, |ui, v| { - egui::ComboBox::from_id_salt("bulk-font").selected_text(tl!(&font_label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-font").selected_text(tl!(&font_label)).show_ui(ui, |ui| { for font in FieldFont::ALL { ui.selectable_value(v, font, font.label()); } @@ -314,7 +314,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool ui.add(egui::DragValue::new(v).range(0.0..=12.0).speed(0.25).suffix(" pt")); }); row(ui, "Line Style:", &mut d.style, |ui, v| { - egui::ComboBox::from_id_salt("bulk-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { for style in BorderStyle::ALL { ui.selectable_value(v, style, tl!(style.label())); } @@ -329,7 +329,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool 2 => "Right", _ => "Left", }; - egui::ComboBox::from_id_salt("bulk-align").selected_text(tl!(label)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-align").selected_text(tl!(label)).show_ui(ui, |ui| { for (value, label) in [(0, "Left"), (1, "Center"), (2, "Right")] { ui.selectable_value(v, value, tl!(label)); } @@ -344,7 +344,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut Draft, t: &theme::Tokens) -> (bool } if d.check_fields { row(ui, "Button Style:", &mut d.check_style, |ui, v| { - egui::ComboBox::from_id_salt("bulk-check-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("bulk-check-style").selected_text(tl!(v.label())).show_ui(ui, |ui| { for style in CheckStyle::ALL { ui.selectable_value(v, style, tl!(style.label())); } diff --git a/crates/ui-egui/src/tools/link_ui.rs b/crates/ui-egui/src/tools/link_ui.rs index f7768912dd..f804f121e9 100644 --- a/crates/ui-egui/src/tools/link_ui.rs +++ b/crates/ui-egui/src/tools/link_ui.rs @@ -229,7 +229,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token widgets::section_title(ui, tl!("Link Appearance")); egui::Grid::new("link-appearance").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Link Type:")); - egui::ComboBox::from_id_salt("link-type") + craft_ui::ComboBox::from_id_salt("link-type") .selected_text(if d.style.visible { tl!("Visible Rectangle") } else { tl!("Invisible Rectangle") }) .show_ui(ui, |ui| { ui.selectable_value(&mut d.style.visible, true, tl!("Visible Rectangle")); @@ -237,7 +237,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token }); ui.end_row(); ui.label(tl!("Highlight Style:")); - egui::ComboBox::from_id_salt("link-hl").selected_text(tl!(d.style.highlight.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("link-hl").selected_text(tl!(d.style.highlight.label())).show_ui(ui, |ui| { for h in LinkHighlight::ALL { ui.selectable_value(&mut d.style.highlight, h, tl!(h.label())); } @@ -245,7 +245,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token ui.end_row(); if d.style.visible { ui.label(tl!("Line Thickness:")); - egui::ComboBox::from_id_salt("link-w") + craft_ui::ComboBox::from_id_salt("link-w") .selected_text(if d.style.width <= 1.0 { tl!("Thin") } else if d.style.width <= 2.0 { @@ -267,7 +267,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut LinkDraft, pages: usize, t: &Token } else { 0 }; - egui::ComboBox::from_id_salt("link-s").selected_text(tl!(["Solid", "Dashed", "Underline"][style])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("link-s").selected_text(tl!(["Solid", "Dashed", "Underline"][style])).show_ui(ui, |ui| { for (k, l) in ["Solid", "Dashed", "Underline"].iter().enumerate() { ui.selectable_value(&mut style, k, tl!(l)); } diff --git a/crates/ui-egui/src/tools/measure_ui.rs b/crates/ui-egui/src/tools/measure_ui.rs index 79670ad304..2e9e600dfc 100644 --- a/crates/ui-egui/src/tools/measure_ui.rs +++ b/crates/ui-egui/src/tools/measure_ui.rs @@ -438,7 +438,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { None => state.error = Some(tl!("Enter a scale such as 1:50.").into()), } } - egui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("measure-common-scales").selected_text(tl!("Common scales")).width(20.0).show_ui(ui, |ui| { for n in RATIOS { if ui.selectable_label(state.ratio_text == format_ratio(n), format_ratio(n)).clicked() { chosen = Some(n); @@ -449,7 +449,7 @@ fn panel_body(app: &mut PdfCraftApp, ui: &mut egui::Ui, _t: &Tokens) { ui.horizontal(|ui| { ui.add(egui::DragValue::new(&mut state.drawing_points).range(0.000001..=1e9)); let page_unit = page_unit_of(state.page_unit, &state.unit, units); - egui::ComboBox::from_id_salt("measure-page-unit").selected_text(page_unit.label()).width(44.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("measure-page-unit").selected_text(page_unit.label()).width(44.0).show_ui(ui, |ui| { for u in PageUnit::ALL { if ui.selectable_label(u == page_unit, u.label()).clicked() { state.page_unit = Some(u); diff --git a/crates/ui-egui/src/tools/prepare/properties.rs b/crates/ui-egui/src/tools/prepare/properties.rs index d37f20a724..8099d57489 100644 --- a/crates/ui-egui/src/tools/prepare/properties.rs +++ b/crates/ui-egui/src/tools/prepare/properties.rs @@ -17,7 +17,7 @@ fn flag_box(ui: &mut egui::Ui, flags: &mut u32, bit: u32, inverted: bool, label: /// Shared PDF `/Q` picker for text and choice-field Options. fn alignment(ui: &mut egui::Ui, quadding: &mut i64) { ui.label(tl!("Alignment:")); - egui::ComboBox::from_id_salt("quadding") + craft_ui::ComboBox::from_id_salt("quadding") .selected_text(tl!(match (*quadding).clamp(0, 2) { 1 => "Center", 2 => "Right", @@ -87,7 +87,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.end_row(); if let Some(l) = d.look.as_mut() { ui.label(tl!("Font:")); - egui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-font").selected_text(l.font.label()).show_ui(ui, |ui| { for f in FieldFont::ALL { ui.selectable_value(&mut l.font, f, f.label()); } @@ -130,14 +130,14 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke tl!("Thick") } }; - egui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-width").selected_text(thick(l.width)).show_ui(ui, |ui| { for (w, label) in [(1.0, tl!("Thin")), (2.0, tl!("Medium")), (3.0, tl!("Thick"))] { ui.selectable_value(&mut l.width, w, label); } }); ui.end_row(); ui.label(tl!("Line Style:")); - egui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("field-style").selected_text(tl!(l.style.label())).show_ui(ui, |ui| { for s in BorderStyle::ALL { ui.selectable_value(&mut l.style, s, tl!(s.label())); } @@ -162,7 +162,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.horizontal(|ui| { let l = ui.label(tl!("Rotation:")); let before = d.rotation; - egui::ComboBox::from_id_salt("field-rotation") + craft_ui::ComboBox::from_id_salt("field-rotation") .selected_text(format!("{}°", d.rotation)) .show_ui(ui, |ui| { for r in [0, 90, 180, 270] { @@ -237,7 +237,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke if let Some(style) = d.check_style.as_mut() { ui.horizontal(|ui| { let l = ui.label(tl!(if d.kind == FormFieldKind::CheckBox { "Check Box Style:" } else { "Button Style:" })); - egui::ComboBox::from_id_salt("check-style") + craft_ui::ComboBox::from_id_salt("check-style") .selected_text(tl!(style.label())) .show_ui(ui, |ui| { for s in pdfcraft_engine::CheckStyle::ALL { @@ -297,7 +297,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme::Toke ui.horizontal(|ui| { ui.label(tl!("Default:")); let shown = if d.default.is_empty() { tl!("(none)").to_string() } else { d.default.clone() }; - egui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("choice-default").selected_text(shown).show_ui(ui, |ui| { ui.selectable_value(&mut d.default, String::new(), tl!("(none)")); for o in d.options.clone() { ui.selectable_value(&mut d.default, o.clone(), o); diff --git a/crates/ui-egui/src/tools/prepare/tabs.rs b/crates/ui-egui/src/tools/prepare/tabs.rs index 730061c61f..b74e67dd19 100644 --- a/crates/ui-egui/src/tools/prepare/tabs.rs +++ b/crates/ui-egui/src/tools/prepare/tabs.rs @@ -35,7 +35,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme let mut picked = cat; ui.horizontal(|ui| { ui.label(tl!("Select format category:")); - egui::ComboBox::from_id_salt("format-cat") + craft_ui::ComboBox::from_id_salt("format-cat") .selected_text(tl!(match cat { Category::None => "None", Category::Number => "Number", @@ -87,7 +87,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-sep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); } @@ -95,7 +95,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.end_row(); ui.label(tl!("Currency symbol:")); ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("fmt-cur") + craft_ui::ComboBox::from_id_salt("fmt-cur") .selected_text(if currency.is_empty() { tl!("None") } else { currency.as_str() }) .width(70.0) .show_ui(ui, |ui| { @@ -107,7 +107,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme }); ui.end_row(); ui.label(tl!("Negative number style:")); - egui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-neg").selected_text(NEGATIVES[(*neg).min(3) as usize]).show_ui(ui, |ui| { for (i, s) in NEGATIVES.iter().enumerate() { ui.selectable_value(neg, i as u8, *s); } @@ -121,7 +121,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme ui.add(egui::DragValue::new(decimals).range(0..=10)); ui.end_row(); ui.label(tl!("Separator style:")); - egui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-psep").selected_text(SEPARATORS[(*sep).min(4) as usize]).show_ui(ui, |ui| { for (i, s) in SEPARATORS.iter().enumerate() { ui.selectable_value(sep, i as u8, *s); } @@ -137,7 +137,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme }; ui.horizontal(|ui| { ui.label(tl!("Format:")); - egui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-date").selected_text(p.as_str()).width(170.0).show_ui(ui, |ui| { for pr in presets { ui.selectable_value(p, pr.to_string(), pr); } @@ -148,7 +148,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme } Format::Special(n) => { let mut idx = *n as usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[idx.min(4)])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { ui.selectable_value(&mut idx, i, tl!(s)); } @@ -161,7 +161,7 @@ pub(super) fn format_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::theme } Format::Mask(m) => { let mut idx = 4usize; - egui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("fmt-special").selected_text(tl!(SPECIALS[4])).show_ui(ui, |ui| { for (i, s) in SPECIALS.iter().enumerate() { ui.selectable_value(&mut idx, i, tl!(s)); } @@ -220,7 +220,7 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them ui.label(tl!("Select Trigger:")); // Editing changes the script of one trigger's action: its trigger and kind stay. ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("action-trigger").selected_text(tl!(a.trigger.label())).width(220.0).show_ui(ui, |ui| { for tr in T::ALL { ui.selectable_value(&mut a.trigger, tr, tl!(tr.label())); } @@ -229,7 +229,7 @@ pub(super) fn actions_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::them ui.end_row(); ui.label(tl!("Select Action:")); ui.add_enabled_ui(!editing, |ui| { - egui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("action-kind").selected_text(tl!(ACTION_KINDS[a.kind])).width(220.0).show_ui(ui, |ui| { for (i, k) in ACTION_KINDS.iter().enumerate() { ui.selectable_value(&mut a.kind, i, tl!(k)); } @@ -350,7 +350,7 @@ pub(super) fn calculate_tab(ui: &mut egui::Ui, d: &mut FieldDraft, t: &crate::th if let Calculate::Simple { op, fields } = &mut d.calculate { ui.horizontal(|ui| { ui.add_space(24.0); - egui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("calc-op").selected_text(tl!(op.label())).show_ui(ui, |ui| { for o in CalcOp::ALL { ui.selectable_value(op, o, tl!(o.label())); } diff --git a/crates/ui-egui/src/tools/redact_ui.rs b/crates/ui-egui/src/tools/redact_ui.rs index 6a5bee7ea4..8e7ac02eef 100644 --- a/crates/ui-egui/src/tools/redact_ui.rs +++ b/crates/ui-egui/src/tools/redact_ui.rs @@ -412,7 +412,7 @@ pub(crate) fn search_body(ui: &mut egui::Ui, d: &mut SearchDraft, t: &Tokens) -> ui.add_space(6.0); widgets::radio_value(ui, &mut d.mode, SearchMode::Patterns, tl!("Patterns")); ui.add_enabled_ui(d.mode == SearchMode::Patterns, |ui| { - egui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("redact-pattern").selected_text(tl!(d.pattern.label())).width(240.0).show_ui(ui, |ui| { for p in REDACT_PATTERNS { ui.selectable_value(&mut d.pattern, p, tl!(p.label())); } @@ -475,7 +475,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> } let codes_on = on && d.use_code; ui.add_enabled_ui(codes_on, |ui| { - egui::ComboBox::from_id_salt("redaction-code-set").selected_text(d.code_set.name).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("redaction-code-set").selected_text(d.code_set.name).show_ui(ui, |ui| { for set in REDACTION_CODE_SETS { if ui.selectable_label(d.code_set == set, set.name).clicked() && d.code_set != set { d.code_set = set; @@ -505,7 +505,7 @@ pub(crate) fn props_body(ui: &mut egui::Ui, d: &mut RedactPrefs, _t: &Tokens) -> let look = &mut d.look; ui.label(tl!("Font:")); ui.add_enabled_ui(on, |ui| { - egui::ComboBox::from_id_salt("overlay-font").selected_text(look.font.name()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("overlay-font").selected_text(look.font.name()).show_ui(ui, |ui| { for f in pdfcraft_engine::OverlayFont::ALL { ui.selectable_value(&mut look.font, f, f.name()); } diff --git a/crates/ui-egui/src/tools/sign_ui/dialog.rs b/crates/ui-egui/src/tools/sign_ui/dialog.rs index 4b327923d4..ad5ef4b01d 100644 --- a/crates/ui-egui/src/tools/sign_ui/dialog.rs +++ b/crates/ui-egui/src/tools/sign_ui/dialog.rs @@ -163,7 +163,7 @@ fn configure(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { ui.add(crate::widgets::line(&mut n.country).hint_text("US").char_limit(2).desired_width(60.0)).labelled_by(l.id); ui.end_row(); ui.label(tl!("Key Algorithm")); - egui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("key-alg").selected_text(KEY_ALGORITHMS[n.key].0).show_ui(ui, |ui| { for (i, (label, _)) in KEY_ALGORITHMS.iter().enumerate() { ui.selectable_value(&mut n.key, i, *label); } @@ -415,7 +415,7 @@ fn sign_as(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { 3 => tl!("Annotations, form fill-in, and digital signatures"), _ => tl!("Form fill-in and digital signatures"), }; - egui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("certify-p").selected_text(label(p)).width(300.0).show_ui(ui, |ui| { for v in [1u8, 2, 3] { ui.selectable_value(&mut p, v, label(v)); } diff --git a/crates/ui-egui/src/tools/stamps_ui.rs b/crates/ui-egui/src/tools/stamps_ui.rs index 1da56c28ed..9cddb31d23 100644 --- a/crates/ui-egui/src/tools/stamps_ui.rs +++ b/crates/ui-egui/src/tools/stamps_ui.rs @@ -67,7 +67,7 @@ pub(crate) fn create_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) ui.horizontal(|ui| { ui.add(crate::widgets::line(&mut d.category).desired_width(200.0).hint_text(tl!("e.g. My stamps"))).labelled_by(l.id); if !categories.is_empty() { - egui::ComboBox::from_id_salt("stamp-categories").selected_text("").width(24.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("stamp-categories").selected_text("").width(24.0).show_ui(ui, |ui| { for c in &categories { if ui.selectable_label(d.category == *c, c).clicked() { d.category = c.clone(); diff --git a/crates/ui-egui/src/workflows/a11y_ui.rs b/crates/ui-egui/src/workflows/a11y_ui.rs index 2130c927a0..9081e5cb32 100644 --- a/crates/ui-egui/src/workflows/a11y_ui.rs +++ b/crates/ui-egui/src/workflows/a11y_ui.rs @@ -103,7 +103,7 @@ pub(crate) fn options_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) group(ui, &crate::i18n::fmt(tl!("Checking Options ({on} of 32 in all categories)"), &[("on", &on.to_string())]), &mut |ui| { ui.horizontal(|ui| { ui.label(tl!("Category:")); - egui::ComboBox::from_id_salt("a11y-category").selected_text(tl!(o.category.label())).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("a11y-category").selected_text(tl!(o.category.label())).width(260.0).show_ui(ui, |ui| { for c in Category::ALL { ui.selectable_value(&mut o.category, c, tl!(c.label())); } diff --git a/crates/ui-egui/src/workflows/actions_ui.rs b/crates/ui-egui/src/workflows/actions_ui.rs index ddd8a0736c..5ef299102f 100644 --- a/crates/ui-egui/src/workflows/actions_ui.rs +++ b/crates/ui-egui/src/workflows/actions_ui.rs @@ -345,11 +345,14 @@ fn edit_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> bool { let all = Step::all(); let add = &mut app.wizard.add; ui.horizontal(|ui| { - egui::ComboBox::from_id_salt("action-add-step").selected_text(tl!(all[(*add).min(all.len() - 1)].label())).width(240.0).show_ui(ui, |ui| { - for (i, s) in all.iter().enumerate() { - ui.selectable_value(add, i, tl!(s.label())); - } - }); + craft_ui::ComboBox::from_id_salt("action-add-step").selected_text(tl!(all[(*add).min(all.len() - 1)].label())).width(240.0).show_ui( + ui, + |ui| { + for (i, s) in all.iter().enumerate() { + ui.selectable_value(add, i, tl!(s.label())); + } + }, + ); if ui.button(tl!("Add Step")).clicked() { a.steps.push(all[(*add).min(all.len() - 1)].clone()); } diff --git a/crates/ui-egui/src/workflows/compare_ui.rs b/crates/ui-egui/src/workflows/compare_ui.rs index fcaa963278..939d5fed3e 100644 --- a/crates/ui-egui/src/workflows/compare_ui.rs +++ b/crates/ui-egui/src/workflows/compare_ui.rs @@ -63,7 +63,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo ui.label(tl!("Old file:")); let shown = others.iter().find(|(id, _)| Some(*id) == app.compare_old).map_or(tl!("Open the older version first").to_string(), |(_, n)| n.clone()); - egui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("compare-old").selected_text(shown).width(320.0).show_ui(ui, |ui| { for (id, n) in &others { ui.selectable_value(&mut app.compare_old, Some(*id), n); } diff --git a/crates/ui-egui/src/workflows/export_ui.rs b/crates/ui-egui/src/workflows/export_ui.rs index fae075e30b..0af69f1823 100644 --- a/crates/ui-egui/src/workflows/export_ui.rs +++ b/crates/ui-egui/src/workflows/export_ui.rs @@ -53,7 +53,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E if kind == ExportKind::Image { ui.horizontal(|ui| { ui.label(tl!("Resolution")); - egui::ComboBox::from_id_salt("export-dpi") + craft_ui::ComboBox::from_id_salt("export-dpi") .selected_text(crate::i18n::fmt(tl!("{dpi} pixels/inch"), &[("dpi", &d.dpi.to_string())])) .show_ui(ui, |ui| { for dpi in [72.0, 96.0, 150.0, 300.0, 600.0] { @@ -67,7 +67,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E ImageFormat::Jpeg { quality } => quality, _ => 85, }; - egui::ComboBox::from_id_salt("export-format").selected_text(d.format.label()).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("export-format").selected_text(d.format.label()).show_ui(ui, |ui| { for f in [ImageFormat::Png, ImageFormat::Jpeg { quality }, ImageFormat::Tiff] { let on = std::mem::discriminant(&d.format) == std::mem::discriminant(&f); if ui.selectable_label(on, f.label()).clicked() { @@ -93,7 +93,7 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens, kind: E let label = move |n: u32| { if n == 0 { tl!("No limit").to_string() } else { crate::i18n::trn(crate::i18n::current(), u64::from(n), "{n} pixel", "{n} pixels") } }; - egui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("export-min").selected_text(label(d.min_side)).show_ui(ui, |ui| { for n in [0, 16, 32, 64, 128, 256] { ui.selectable_value(&mut d.min_side, n, label(n)); } @@ -176,12 +176,18 @@ fn run( // Handled above. ExportKind::AllImages => String::new(), ExportKind::Image => { - for (k, p) in pages.iter().enumerate() { - set(k, None); - let result = ex.image(*p, dpi, format).and_then(|img| sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)); - if let Err(e) = result { - return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); - } + set(0, None); + // Pages are drawn several at once and handed over in order: the progress counts the + // pages written so far. + let mut done = 0; + let result = ex.images(&pages, dpi, format, |p, img| { + sink(&format!("{stem}_page_{}.{}", p + 1, format.extension()), img)?; + done += 1; + set(done, None); + Ok(()) + }); + if let Err(e) = result { + return crate::i18n::fmt(tl!("Export stopped: {e}"), &[("e", &e.to_string())]); } let mut msg = if total == 1 { crate::i18n::fmt(tl!("Exported 1 image"), &[]) diff --git a/crates/ui-egui/src/workflows/js_ui.rs b/crates/ui-egui/src/workflows/js_ui.rs index 84aa706576..0ebdbb07a6 100644 --- a/crates/ui-egui/src/workflows/js_ui.rs +++ b/crates/ui-egui/src/workflows/js_ui.rs @@ -332,7 +332,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok (selected, picker) }; let before = app.language.clone(); - egui::ComboBox::from_id_salt("interface-language") + craft_ui::ComboBox::from_id_salt("interface-language") .selected_text(selected) .show_ui(ui, |ui| { if ui.selectable_value(&mut app.language, crate::i18n::AUTO.to_string(), tl!("Auto")).clicked() { @@ -359,7 +359,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok let auto_percent = ui_scale::percent(ui_scale::drawn(None, app.system_text_scale, native)); let auto = crate::i18n::fmt(tl!("Auto ({percent}%)"), &[("percent", &auto_percent.to_string())]); let selected = app.ui_scale.map_or_else(|| auto.clone(), |f| format!("{}%", ui_scale::percent(f))); - egui::ComboBox::from_id_salt("interface-size") + craft_ui::ComboBox::from_id_salt("interface-size") .selected_text(selected) .show_ui(ui, |ui| { if ui.selectable_value(&mut app.ui_scale, None, auto).clicked() { @@ -485,7 +485,7 @@ pub(crate) fn preferences_body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tok ui.horizontal(|ui| { let label = ui.label(tl!("Replace document colours")); let mode = app.reading_colors.mode_name(); - egui::ComboBox::from_id_salt("reading-colors") + craft_ui::ComboBox::from_id_salt("reading-colors") .selected_text(tl!(reading_mode_label(mode))) .show_ui(ui, |ui| { for (m, label) in [ @@ -590,7 +590,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { let label = ui.label(tl!("Fill & Sign dates")); let current = app.session.date_format().to_string(); let selected = if DATE_FORMATS.contains(¤t.as_str()) { sample(app, ¤t) } else { tl!("Custom").to_string() }; - egui::ComboBox::from_id_salt("date-format") + craft_ui::ComboBox::from_id_salt("date-format") .selected_text(selected) .width(240.0) .show_ui(ui, |ui| { @@ -652,7 +652,7 @@ fn date_format(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) { let names = DATE_LANGUAGES.iter().map(|l| (l, date_language_picker_name(l, drawable))).collect::>(); (follow, selected, current, names) }; - egui::ComboBox::from_id_salt("date-language") + craft_ui::ComboBox::from_id_salt("date-language") .selected_text(selected) .width(240.0) .show_ui(ui, |ui| { diff --git a/crates/ui-egui/src/workflows/ocr_ui.rs b/crates/ui-egui/src/workflows/ocr_ui.rs index 977f90f2f9..04e257d52d 100644 --- a/crates/ui-egui/src/workflows/ocr_ui.rs +++ b/crates/ui-egui/src/workflows/ocr_ui.rs @@ -121,21 +121,21 @@ pub(crate) fn body(ui: &mut egui::Ui, app: &mut PdfCraftApp, t: &Tokens) -> (boo egui::Grid::new("ocr-settings").num_columns(2).spacing([12.0, 8.0]).show(ui, |ui| { ui.label(tl!("Document language")); let name = LANGUAGES.iter().find(|l| l.0 == d.language).map_or("English", |l| l.1); - egui::ComboBox::from_id_salt("ocr-language").selected_text(tl!(name)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-language").selected_text(tl!(name)).width(220.0).show_ui(ui, |ui| { for (code, name) in LANGUAGES { ui.selectable_value(&mut d.language, (*code).to_string(), tl!(*name)); } }); ui.end_row(); ui.label(tl!("Output")); - egui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-output").selected_text(tl!("Searchable Image (Exact)")).width(220.0).show_ui(ui, |ui| { let _ = ui .selectable_label(true, tl!("Searchable Image (Exact)")) .on_hover_text(tl!("Adds invisible text over each word; the page image is not changed")); }); ui.end_row(); ui.label(tl!("Downsample to")); - egui::ComboBox::from_id_salt("ocr-dpi").selected_text(format!("{} dpi", d.dpi)).width(220.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("ocr-dpi").selected_text(format!("{} dpi", d.dpi)).width(220.0).show_ui(ui, |ui| { for v in [600, 300, 150, 72] { ui.selectable_value(&mut d.dpi, v, format!("{v} dpi")); } diff --git a/crates/ui-egui/src/workflows/optimize_ui.rs b/crates/ui-egui/src/workflows/optimize_ui.rs index d46d7ae591..3bb80ac439 100644 --- a/crates/ui-egui/src/workflows/optimize_ui.rs +++ b/crates/ui-egui/src/workflows/optimize_ui.rs @@ -121,7 +121,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { Compression::Flate => tl!("ZIP"), Compression::Retain => tl!("Retain existing"), }; - egui::ComboBox::from_id_salt((id, "compression")).selected_text(label).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt((id, "compression")).selected_text(label).show_ui(ui, |ui| { let q = match s.compression { Compression::Jpeg(q) => q, _ => 60, @@ -133,7 +133,7 @@ fn image_row(ui: &mut egui::Ui, id: &str, title: &str, s: &mut ImageSettings) { if let Compression::Jpeg(q) = &mut s.compression { ui.label(tl!("Quality")); let name = QUALITIES.iter().min_by_key(|(_, v)| (*v as i32 - *q as i32).abs()).map_or(tl!("Medium"), |(n, _)| tl!(n)); - egui::ComboBox::from_id_salt((id, "quality")).selected_text(name).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt((id, "quality")).selected_text(name).show_ui(ui, |ui| { for (n, v) in QUALITIES { ui.selectable_value(q, v, tl!(n)); } @@ -165,7 +165,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo Preset::Mobile => tl!("Mobile"), }; let current = Preset::of(&d.settings); - egui::ComboBox::from_id_salt("optimize-preset").selected_text(current.map_or(tl!("Custom"), name)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("optimize-preset").selected_text(current.map_or(tl!("Custom"), name)).show_ui(ui, |ui| { for p in Preset::ALL { if ui.selectable_label(current == Some(p), name(p)).clicked() { d.settings = p.settings(); @@ -178,7 +178,7 @@ pub(crate) fn body(ui: &mut egui::Ui, d: &mut OptimizeDraft, t: &Tokens) -> (boo Compatibility::Retain => tl!("Retain existing").to_string(), _ => format!("PDF {}", c.name()), }; - egui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("optimize-compatibility").selected_text(compat_label(d.settings.compatibility)).show_ui(ui, |ui| { for c in Compatibility::ALL { ui.selectable_value(&mut d.settings.compatibility, c, compat_label(c)); } diff --git a/crates/ui-egui/src/workflows/print_ui/body.rs b/crates/ui-egui/src/workflows/print_ui/body.rs index baf5c02db0..5243820396 100644 --- a/crates/ui-egui/src/workflows/print_ui/body.rs +++ b/crates/ui-egui/src/workflows/print_ui/body.rs @@ -36,7 +36,7 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { let label_of = |c: &str| o.choices.iter().find(|(k, _)| k == c).map_or_else(|| c.to_string(), |(_, l)| l.clone()); let mut pick = current.clone(); let option_label = ui.label(&o.label); - egui::ComboBox::from_id_salt(("driver-option", &o.key)) + craft_ui::ComboBox::from_id_salt(("driver-option", &o.key)) .selected_text(label_of(¤t)) .width(200.0) .show_ui(ui, |ui| { @@ -64,7 +64,7 @@ fn driver_options_panel(ui: &mut egui::Ui, d: &mut PrintDraft, t: &Tokens) { /// Draw a combo box whose accessible name does not depend on the selected value. fn combo(ui: &mut egui::Ui, id: &str, name: &str, value: &mut T, choices: &[(T, &str)], width: f32) { let shown = choices.iter().find(|c| c.0 == *value).map_or("", |c| c.1); - egui::ComboBox::from_id_salt(id) + craft_ui::ComboBox::from_id_salt(id) .selected_text(shown) .width(width) .show_ui(ui, |ui| { @@ -107,7 +107,7 @@ pub(crate) fn body( None if still_looking(d) => tl!("Looking for printers…").to_string(), None => tl!("Save as PDF").to_string(), }; - egui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("printer").selected_text(shown).width(260.0).show_ui(ui, |ui| { if d.printers_loading { ui.add_enabled(false, egui::Label::new(tl!("Looking for printers…"))); } @@ -201,7 +201,7 @@ pub(crate) fn body( let paper_label = ui.label(tl!("Paper:")); let paper_name = PAPERS.get(d.paper).map_or("US Letter", |p| p.0); let before = d.paper; - egui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("paper").selected_text(tl!(paper_name)).width(260.0).show_ui(ui, |ui| { for (i, (name, _)) in PAPERS.iter().enumerate() { ui.selectable_value(&mut d.paper, i, tl!(name)); } diff --git a/docs/releasing.md b/docs/releasing.md index b6bcb1abf8..72206e16f1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -69,13 +69,20 @@ pinned in `release.yml` and builds with `CRAFT_FONTS_DIR` and `CRAFT_FONTS_REQUI embed its Japanese fonts and fail rather than ship without them (`AGENTS.md` §1.4). Bump the pin deliberately. -**OCR models:** every desktop package ships the Scan & OCR models (#103). The packaging scripts call -`stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to -fetch every `kind = "model"` file in `ATTRIBUTION.toml` (verified by SHA-256) with its licence text -and an `ATTRIBUTION.txt`, straight into the package, so packaging needs the network. They go where -`pdfcraft_ocr::Models::dirs_beside_exe` looks: `models\` beside `pdfcraft.exe` (MSI, portable zip), -`PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` -(deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). The web build doesn't include them yet. +**OCR models:** desktop packaging stages the Scan & OCR models (#103). +The packaging scripts call `stage_models` (`packaging/env.sh`; `package.ps1` on Windows), which runs `cargo xtask models` to fetch every `kind = "model"` file in `ATTRIBUTION.toml` and verify its SHA-256. +The current detection and recognition files are `text-detection.onnx` and `text-recognition.onnx`. +Each model ships with its licence text and the directory's `ATTRIBUTION.txt`, so packaging needs the network. +Upstream announced the legacy RTEN download URLs' brownout in [ocrs #282](https://github.com/robertknight/ocrs/issues/282) on 2026-10-27 and directs consumers to the ONNX models. + +Runtime discovery prefers a complete ONNX detection/recognition pair within each directory, then a complete legacy `text-detection.rten` / `text-recognition.rten` pair. +An incomplete ONNX update can therefore use an intact RTEN pair in the same directory. +Discovery never combines model formats or files from different directories. +Directory precedence stays the same: `$PDFCRAFT_MODELS`, the release-package locations beside the executable, then the source tree's `assets/models/`. +The first directory with a complete supported pair wins, even if an earlier legacy pair precedes a later ONNX pair. + +The release-package locations are `models\` beside `pdfcraft.exe` (MSI, portable zip), `PdfCraft.app/Contents/Resources/models` (macOS), and `share/pdfcraft/models` beside `bin/` (deb, rpm, tar.gz, AppImage, Flatpak, FreeBSD). +The web build does not include these models, and OCR remains limited to the Latin alphabet without accents. ### macOS diff --git a/docs/roadmap.md b/docs/roadmap.md index 40fa711c24..25205e92c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,5 +1,6 @@ # PdfCraft roadmap: milestones and what's next +> **Last reviewed:** 2026-10-27 | **Last updated:** 2026-10-27 | **Change:** minor (M10: OCR model downloads use verified ONNX pairs with legacy discovery fallback; estimates unchanged) > **Last reviewed:** 2026-10-25 | **Last updated:** 2026-10-25 | **Change:** minor (M14 localization current state: Arabic logical catalog, explicit RTL label direction and mirrored stock chrome; native/browser RTL workflows unqualified. Native release Hans face via LXGW WenKai Lite; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M7, #2636 partial: pasting added text boxes and images in the engine and through `content_copy`; estimates unchanged) > **Last reviewed:** 2026-10-23 · **Last updated:** 2026-10-23 · **Change:** minor (M11: image optimization accounts for page-local UserUnit; estimates unchanged) @@ -193,7 +194,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.
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). 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.
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 (baked Not Verified captions remain; #79 open), snapshot tabs that reuse instead of multiplying. 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).
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). 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. | +| 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).
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. | | 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). 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.
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
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
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. | @@ -254,6 +255,7 @@ Estimates unchanged. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | M10: OCR stages SHA-256-verified ONNX models after the upstream RTEN URL brownout; discovery prefers a complete ONNX pair per directory, falls back to a complete RTEN pair, and never mixes formats or directories; language/web limits and estimates unchanged. | | 2026-10-24 | minor | M3, #3970 follow-up: the button release given back after a window move or resize goes before the pointer leaving, so hover doesn't stick. Estimates unchanged. | | 2026-10-23 | minor | M8, #3205 follow-up: phone-pattern redaction includes the +39 country code on labelled Italian geographic numbers with a slash; dates, protocol identifiers and whole-run guards remain. Estimates unchanged. | | 2026-10-23 | minor | M10: Word table cells use the grid columns they span, including cells after a merged cell; synthetic export regression and CLI reproduction. Estimates unchanged. | diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 59b1c13696..5dc4152d8f 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -268,13 +268,16 @@ $modelsDir = Read-Row 'SELECT `Directory_Parent`, `DefaultDir` FROM `Directory` Assert-Equal $modelsDir[0] 'INSTALLFOLDER' 'OCR models folder parent' # DefaultDir is `models`, or `SHORT|models` with a generated 8.3 name. if ($modelsDir[1] -notmatch '(^|\|)models$') { throw "OCR models folder name: '$($modelsDir[1])'" } -$modelFiles = 0 +$modelFiles = @() $view = $Database.OpenView('SELECT `File`.`FileName` FROM `File`, `Component` WHERE `File`.`Component_` = `Component`.`Component` AND `Component`.`Directory_` = ''ModelsFolder''') try { [void] $view.Execute() - while ($record = $view.Fetch()) { if ($record.StringData(1) -match '\.rten$') { $modelFiles++ } } + # FileName can contain a generated 8.3 name before the long name: SHORT|long. + while ($record = $view.Fetch()) { $modelFiles += ($record.StringData(1) -split '\|')[-1] } } finally { [void] $view.Close() } -if ($modelFiles -lt 2) { throw "expected the OCR models (*.rten) in models\, found $modelFiles" } +foreach ($model in @('text-detection.onnx', 'text-recognition.onnx')) { + Assert-Equal (@($modelFiles | Where-Object { $_ -ceq $model }).Count) 1 "OCR model $model in models\" +} $rm = Read-Row 'SELECT `Dialog` FROM `Dialog` WHERE `Dialog` = ''MsiRMFilesInUse''' 1 Assert-Equal $rm[0] 'MsiRMFilesInUse' 'Files-in-use dialog' [void] [Runtime.InteropServices.Marshal]::FinalReleaseComObject($Database) diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index bcba7a0ecd..47ac06fdd4 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -6544,8 +6544,8 @@ milestone = "M10" status = "shipped" commands = ["ocr.recognize"] tools = ["ocr_recognize", "ocr_status"] -evidence = ["crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] -notes = "ocrs engine (MIT/Apache) with CC-BY-SA models fetched by `cargo xtask models`; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." +evidence = ["crates/ocr/src/lib.rs::model_discovery_prefers_the_complete_onnx_pair", "crates/ocr/src/lib.rs::model_discovery_falls_back_to_legacy_when_the_onnx_pair_is_incomplete", "crates/ocr/src/lib.rs::model_discovery_refuses_mixed_format_pairs", "crates/ocr/src/lib.rs::model_discovery_keeps_legacy_only_installations", "crates/engine/src/tests.rs::recognize_text_makes_a_scanned_page_searchable", "crates/automation/tests/it/automation.rs::ocr_tools_make_a_scan_searchable", "crates/ui-egui/tests/it/ocr.rs::recognize_text_dialog_adds_searchable_text"] +notes = "ocrs engine (MIT/Apache) with CC-BY-SA ONNX models fetched and SHA-256 checked by `cargo xtask models`; within each search directory, a complete ONNX pair takes precedence over a complete legacy RTEN pair, incomplete and mixed-format pairs are not combined, and directory precedence is unchanged; the desktop release packages ship them (#103; packaging/env.sh stage_models), the web build does not yet; runs on a worker thread with progress; one undoable step; pages with text are skipped unless asked (ocr.skip-renderable-text)." [[feature]] id = "ocr.recognize-range"