From ef6d29c99bba445da8635d08ff7c3e082fe0aaaf Mon Sep 17 00:00:00 2001 From: Anthony Ronning <101225832+AnthonyRonning@users.noreply.github.com> Date: Tue, 21 Jul 2026 04:33:17 +0000 Subject: [PATCH 1/4] feat: add local PDF OCR with PDFOxide --- docs/pdf-ocr.md | 59 + frontend/src-tauri/Cargo.lock | 1344 +++++++++++++++++++-- frontend/src-tauri/Cargo.toml | 20 +- frontend/src-tauri/src/lib.rs | 1 + frontend/src-tauri/src/pdf_extractor.rs | 662 +++++++++- frontend/src-tauri/src/pdf_ocr.rs | 320 +++++ frontend/src/components/UnifiedChat.tsx | 104 +- frontend/src/utils/documentUpload.test.ts | 33 + frontend/src/utils/documentUpload.ts | 24 + scripts/ci/ios-pr.sh | 3 + scripts/ci/ios-release.sh | 3 + 11 files changed, 2410 insertions(+), 163 deletions(-) create mode 100644 docs/pdf-ocr.md create mode 100644 frontend/src-tauri/src/pdf_ocr.rs create mode 100644 frontend/src/utils/documentUpload.test.ts create mode 100644 frontend/src/utils/documentUpload.ts diff --git a/docs/pdf-ocr.md b/docs/pdf-ocr.md new file mode 100644 index 00000000..101950c6 --- /dev/null +++ b/docs/pdf-ocr.md @@ -0,0 +1,59 @@ +# PDF extraction and OCR + +Maple uses [`pdf_oxide` 0.3.74](https://crates.io/crates/pdf_oxide/0.3.74) for native PDF text extraction and page classification. Pages classified as scanned or image-backed are OCR'd locally with PDFOxide's PaddleOCR integration. PDF bytes and rendered pages never leave the device. + +## Inference backend + +Maple enables PDFOxide's `ocr-tract` and `rendering` features on macOS, Windows, Linux, iOS, and Android. Tract executes the same ONNX models in pure Rust on every target. + +PDFOxide's alternative `ocr` feature currently pins `ort = 2.0.0-rc.11` and unconditionally enables dynamic ONNX Runtime loading. Using it would conflict with Maple's statically linked iOS runtime, add an ONNX Runtime requirement to Android, and drop the official Intel macOS runtime that Maple's universal macOS 13.3 build still supports. The existing TTS ONNX Runtime therefore remains on its independently tested version; it does not participate in PDF OCR. + +The application constructs and reuses one `OcrEngine`. Native-only PDFs bypass model setup entirely. OCR-routed pages are rendered once in full, bounded to a 2,000 by 2,000-pixel box, so tiled scans, inline images, and vector content are not lost by selecting a single embedded image. Mixed pages retain native text and append only OCR detection spans not already represented in the native layer. OCR is optional enrichment for native-readable hybrid pages: if models or OCR are unavailable, Maple keeps the native text instead of making an ordinary digital PDF depend on the network. + +## Model supply and cache + +The first scanned PDF downloads 12,577,821 bytes into the application cache under `ocr/models/paddleocr-en-v1`. Mobile operating systems may purge that cache, in which case Maple downloads and verifies it again. + +Maple does not use PDFOxide's mutable `resolve/main` downloader. Every artifact is pinned to an immutable repository revision and checked for its exact length and SHA-256 before it is loaded: + +| File | Immutable source | Bytes | SHA-256 | +| --- | --- | ---: | --- | +| `det.onnx` | [SWHL RapidOCR PaddleOCR detector](https://huggingface.co/SWHL/RapidOCR/blob/1cfba2e90fc938db55889873735088de210cc173/PP-OCRv4/ch_PP-OCRv4_det_infer.onnx) | 4,745,517 | `d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9` | +| `rec.onnx` | [monkt English recognizer](https://huggingface.co/monkt/paddleocr-onnx/blob/7b02d0a30a07ba2b92ad1ff5a8941ae2c633de65/languages/english/rec.onnx) | 7,830,888 | `4e16deb22c4da6468bdca539b2cd3c8687825538b67109177c47d359ab994cd7` | +| `en_dict.txt` | [monkt English dictionary](https://huggingface.co/monkt/paddleocr-onnx/blob/7b02d0a30a07ba2b92ad1ff5a8941ae2c633de65/languages/english/dict.txt) | 1,416 | `e025a66d31f327ba0c232e03f407ae8d105e1e709e7ccb3f408aa778c24e70d6` | + +PaddleOCR, SWHL RapidOCR, and the monkt model repository declare Apache-2.0. Maple uses the SWHL detector rather than PDFOxide's default custom-notice mirror; the unforked backend accepts the model directly and the model-backed test verifies compatibility. + +On Android, the model-only HTTP client uses rustls with the standard Mozilla WebPKI roots. This avoids reqwest's separate Android platform-verifier Kotlin/JNI setup; desktop and iOS retain their normal platform trust stores. + +## Panic and error boundary + +All PDF parsing, classification, rendering, and inference work runs in an isolated blocking task. An ordinary Rust unwind becomes a normal Tauri command error, so the frontend clears its busy state and remains usable. Process aborts and native stack exhaustion cannot be recovered by a Rust unwind boundary. Maple therefore serializes PDF jobs, bounds OCR renders to four megapixels, and rejects OCR pages above conservative source-image count and pixel budgets before PDFOxide decodes them. + +The backend independently enforces 10 MiB limits on both the input document and extracted text, rejects locked PDFs with a specific message, and treats a document with no recognized text as an error rather than silently attaching an empty document. + +## Known upstream opportunities + +No PDFOxide fork is required. Potential upstream contributions that would simplify Maple's integration are: + +- enable `AutoExtractor` OCR routing and model prefetch for `ocr-tract`, not only `ocr`; +- accept a reusable caller-owned `OcrEngine` in the whole-document auto extractor; +- expose the native/OCR merge helper; +- integrate the public full-page renderer as an OCR fallback; +- publish the inline-image decoding fix currently newer than 0.3.74. +- publish normal Rust library metadata separately from the `cdylib` and `staticlib` FFI artifacts, reducing multi-target build time and disk use. + +Maple's current model pack recognizes English text. Additional language packs, password entry, and OCR download progress beyond the attachment spinner are separate product work. + +## Model-backed test + +The focused test can be run with a scanned fixture and the three verified model files: + +```sh +MAPLE_OCR_TEST_PDF=/absolute/path/to/scanned.pdf \ +MAPLE_OCR_MODEL_DIR=/absolute/path/to/models \ +nix develop -c cargo test --release --locked --lib \ + extracts_scanned_pdf_with_real_ocr_models -- --ignored --nocapture +``` + +Use the release profile for this model-backed test. Tract performs expensive graph specialization in an unoptimized build and is not representative of the packaged application. diff --git a/frontend/src-tauri/Cargo.lock b/frontend/src-tauri/Cargo.lock index 40a90183..65bd67f7 100644 --- a/frontend/src-tauri/Cargo.lock +++ b/frontend/src-tauri/Cargo.lock @@ -8,15 +8,6 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" -[[package]] -name = "adobe-cmap-parser" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae8abfa9a4688de8fc9f42b3f013b6fffec18ed8a554f5f113577e0b9b3212a3" -dependencies = [ - "pom", -] - [[package]] name = "aead" version = "0.5.2" @@ -34,10 +25,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] +[[package]] +name = "aes" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +dependencies = [ + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.0", +] + [[package]] name = "aes-gcm" version = "0.10.3" @@ -45,8 +47,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1" dependencies = [ "aead", - "aes", - "cipher", + "aes 0.8.4", + "cipher 0.4.4", "ctr", "ghash", "subtle", @@ -185,7 +187,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3" dependencies = [ "android_log-sys", - "env_filter", + "env_filter 0.1.4", "log", ] @@ -254,6 +256,18 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "anymap2" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d301b3b94cb4b2f23d7917810addbbaff90738e0ca2be692bd027e70d7e0330c" + +[[package]] +name = "anymap3" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb5dfbc6d8d2675589ccbe4d0fd61df2419075625f8c1a62325e718e2b0049f9" + [[package]] name = "arbitrary" version = "1.4.2" @@ -301,7 +315,7 @@ dependencies = [ "asn1-rs-derive", "asn1-rs-impl", "displaydoc", - "nom", + "nom 7.1.3", "num-traits", "rusticata-macros", "thiserror 1.0.69", @@ -528,6 +542,16 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atoi_simd" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3cdb3708a128e559a30fb830e8a77a5022ee6902806925c216658652b452a44" +dependencies = [ + "debug_unsafe", + "rustversion", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -653,15 +677,30 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -727,6 +766,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f1dd022ef4e93f8a438b4ba958de7f64308434fa6a87104481645cc30068b" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block2" version = "0.5.1" @@ -878,6 +926,20 @@ name = "bytemuck" version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] [[package]] name = "byteorder" @@ -885,6 +947,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.11.1" @@ -971,6 +1039,15 @@ dependencies = [ "toml 0.9.8", ] +[[package]] +name = "cbc" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2dc9ee5f88d11e0beb842c88b33c8a5cf0d1329c4b19494af42b07dbfe8896" +dependencies = [ + "cipher 0.5.2", +] + [[package]] name = "cc" version = "1.2.61" @@ -1029,7 +1106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" dependencies = [ "cfg-if", - "cipher", + "cipher 0.4.4", "cpufeatures 0.2.17", ] @@ -1052,7 +1129,7 @@ checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" dependencies = [ "aead", "chacha20 0.9.1", - "cipher", + "cipher 0.4.4", "poly1305", "zeroize", ] @@ -1115,10 +1192,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.6", - "inout", + "inout 0.1.4", "zeroize", ] +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "crypto-common 0.2.2", + "inout 0.2.2", +] + [[package]] name = "clap" version = "4.5.51" @@ -1234,6 +1321,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1370,6 +1463,12 @@ dependencies = [ "libm", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1549,7 +1648,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "cipher", + "cipher 0.4.4", ] [[package]] @@ -1678,13 +1777,50 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "debug_unsafe" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eed2c4702fa172d1ce21078faa7c5203e69f5394d48cc436d25928394a867a2" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + [[package]] name = "der" version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] @@ -1697,7 +1833,7 @@ checksum = "5cd0a5c643689626bec213c4d8bd4d96acc8ffdb4ad4bb6bc16abf27d5f4b553" dependencies = [ "asn1-rs", "displaydoc", - "nom", + "nom 7.1.3", "num-bigint", "num-traits", "rusticata-macros", @@ -1713,6 +1849,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive-new" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3418329ca0ad70234b9735dc4ceed10af4df60eff9c8e7b06cb5e520d92c3535" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "derive_arbitrary" version = "1.4.2" @@ -1785,7 +1932,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "const-oid", + "const-oid 0.9.6", "crypto-common 0.1.6", "subtle", ] @@ -1797,6 +1944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1891,7 +2039,7 @@ version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" dependencies = [ - "bit-set", + "bit-set 0.8.0", "cssparser", "foldhash 0.2.0", "html5ever", @@ -1906,6 +2054,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dpi" version = "0.1.2" @@ -1957,6 +2111,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "dyn-hash" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15401da73a9ed8c80e3b2d4dc05fe10e7b72d7243b9f614e516a44fa99986e88" + [[package]] name = "ecdsa" version = "0.16.9" @@ -2076,6 +2236,29 @@ dependencies = [ "regex", ] +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" +dependencies = [ + "anstream", + "anstyle", + "env_filter 1.0.0", + "jiff", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -2132,9 +2315,9 @@ dependencies = [ [[package]] name = "euclid" -version = "0.20.14" +version = "0.22.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bb7ef65b3777a325d1eeefefab5b6d4959da54747e33bd6258e789640f307ad" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" dependencies = [ "num-traits", ] @@ -2167,7 +2350,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab" dependencies = [ "futures-core", - "nom", + "nom 7.1.3", "pin-project-lite", ] @@ -2177,17 +2360,41 @@ version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72cf461f865c862bb7dc573f643dd6a2b6842f7c30b07882b56bd148cc2761b8" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] +[[package]] +name = "fast-float2" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8eb564c5c7423d25c886fb561d1e4ee69f72354d16918afa32c08811f6b6a55" + +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastrand" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -2263,6 +2470,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", + "libz-rs-sys", "miniz_oxide", ] @@ -2306,6 +2514,38 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.3.2" @@ -2677,9 +2917,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -2699,7 +2941,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" dependencies = [ "color_quant", - "weezl", + "weezl 0.1.10", ] [[package]] @@ -2841,7 +3083,7 @@ dependencies = [ "icu_calendar", "icu_locale", "ignore", - "image", + "image 0.24.9", "include_dir", "indexmap 2.12.0", "indoc", @@ -2853,7 +3095,7 @@ dependencies = [ "nanoid", "oauth2", "once_cell", - "pastey", + "pastey 0.2.3", "process-wrap", "pulldown-cmark", "rand 0.10.2", @@ -2990,6 +3232,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "grid" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" + [[package]] name = "group" version = "0.13.0" @@ -3080,6 +3328,7 @@ checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "num-traits", "zerocopy", ] @@ -3097,6 +3346,9 @@ name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash 0.8.12", +] [[package]] name = "hashbrown" @@ -3129,6 +3381,27 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "hayro-ccitt" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f4d0e94ddd48749f06bbe4e5389fb9799a0c45bcaf00495042076ef05e3241a" + +[[package]] +name = "hayro-jbig2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69374b3668dd45aeb3d3145cda68f2c7b4f223aaa2511e67d076f1c7d741388d" +dependencies = [ + "hayro-ccitt", +] + +[[package]] +name = "hayro-jpeg2000" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bab0c77d09c0d9b144d429d0bf0fcd4c63c01d5f6c33f9b8ed501283e0f1ef76" + [[package]] name = "heck" version = "0.4.1" @@ -3521,6 +3794,22 @@ dependencies = [ "png 0.17.16", ] +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", +] + [[package]] name = "include_dir" version = "0.7.4" @@ -3590,6 +3879,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "block-padding", + "hybrid-array", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -3631,6 +3930,33 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.15" @@ -3661,20 +3987,56 @@ dependencies = [ ] [[package]] -name = "jni" -version = "0.21.1" +name = "jiff" +version = "0.2.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.0", + "defmt", + "jiff-core", + "jiff-static", "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.0", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] [[package]] name = "jni" @@ -3746,6 +4108,9 @@ name = "jpeg-decoder" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" +dependencies = [ + "rayon", +] [[package]] name = "js-sys" @@ -3848,6 +4213,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "kstring" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "558bf9508a558512042d3095138b1f7b8fe90c5467d94f9f1da28b3731c5dbd1" +dependencies = [ + "serde", + "static_assertions", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec 1.15.1", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -3944,12 +4331,75 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-rs-sys" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c10501e7805cee23da17c7790e59df2870c0d4043ec6d03f67d31e2b53e77415" +dependencies = [ + "zlib-rs", +] + [[package]] name = "linux-raw-sys" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +[[package]] +name = "liquid" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a494c3f9dad3cb7ed16f1c51812cbe4b29493d6c2e5cd1e2b87477263d9534d" +dependencies = [ + "liquid-core", + "liquid-derive", + "liquid-lib", + "serde", +] + +[[package]] +name = "liquid-core" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc623edee8a618b4543e8e8505584f4847a4e51b805db1af6d9af0a3395d0d57" +dependencies = [ + "anymap2", + "itertools 0.14.0", + "kstring", + "liquid-derive", + "pest", + "pest_derive", + "regex", + "serde", + "time", +] + +[[package]] +name = "liquid-derive" +version = "0.26.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de66c928222984aea59fcaed8ba627f388aaac3c1f57dcb05cc25495ef8faefe" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "liquid-lib" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9befeedd61f5995bc128c571db65300aeb50d62e4f0542c88282dbcb5f72372a" +dependencies = [ + "itertools 0.14.0", + "liquid-core", + "percent-encoding", + "regex", + "time", + "unicode-segmentation", +] + [[package]] name = "litemap" version = "0.8.1" @@ -3980,24 +4430,6 @@ dependencies = [ "value-bag", ] -[[package]] -name = "lopdf" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5c8ecfc6c72051981c0459f75ccc585e7ff67c70829560cda8e647882a9abff" -dependencies = [ - "encoding_rs", - "flate2", - "indexmap 2.12.0", - "itoa", - "log", - "md-5", - "nom", - "rangemap", - "time", - "weezl", -] - [[package]] name = "lru" version = "0.18.0" @@ -4022,15 +4454,16 @@ dependencies = [ "futures-util", "goose", "hound", + "image 0.25.10", "keyring", "libc", "log", "maple-proxy", - "ndarray", + "ndarray 0.16.1", "once_cell", "openssl", "ort", - "pdf-extract", + "pdf_oxide", "plist", "process-wrap", "rand 0.8.6", @@ -4038,6 +4471,7 @@ dependencies = [ "regex", "reqwest 0.13.2", "rmcp", + "rustls", "serde", "serde_json", "sha2 0.10.9", @@ -4053,9 +4487,11 @@ dependencies = [ "tauri-plugin-sign-in-with-apple", "tauri-plugin-single-instance", "tauri-plugin-updater", + "tempfile", "tokio", "tokio-util", "unicode-normalization", + "webpki-roots", "windows 0.62.2", ] @@ -4083,6 +4519,12 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "markup5ever" version = "0.38.0" @@ -4129,12 +4571,31 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memo-map" version = "0.3.3" @@ -4215,6 +4676,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.2" @@ -4278,6 +4749,21 @@ dependencies = [ "rayon", ] +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "ndk" version = "0.9.0" @@ -4349,6 +4835,24 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom-language" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2de2bc5b451bfedaef92c90b8939a8fff5770bdcc1fafd6239d086aab8fa6b29" +dependencies = [ + "nom 8.0.0", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4782,6 +5286,24 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "office_oxide" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7150eeae2b40a6926717058d0575ee2f48a36806725376a2ff6b2e69b61c88e9" +dependencies = [ + "atoi_simd", + "encoding_rs", + "fast-float2", + "libc", + "log", + "quick-xml 0.41.0", + "serde", + "serde_json", + "thiserror 2.0.18", + "zip 8.6.0", +] + [[package]] name = "oid-registry" version = "0.7.1" @@ -4950,7 +5472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fa7e49bd669d32d7bc2a15ec540a527e7764aec722a45467814005725bcd721" dependencies = [ "libloading 0.8.9", - "ndarray", + "ndarray 0.16.1", "ort-sys", "smallvec 2.0.0-alpha.10", "tracing", @@ -5067,6 +5589,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pastey" version = "0.2.3" @@ -5080,18 +5608,58 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] -name = "pdf-extract" -version = "0.7.12" +name = "pdf_oxide" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbb3a5387b94b9053c1e69d8abfd4dd6dae7afda65a5c5279bc1f42ab39df575" +checksum = "1b97a8330bf3a3a4011ad84704b3bdcb9b0ef3fd5b03a1bdc371dde40bee1689" dependencies = [ - "adobe-cmap-parser", + "aes 0.9.1", + "base64 0.22.1", + "bitflags 2.10.0", + "brotli", + "byteorder", + "bytes", + "cbc", + "chrono", "encoding_rs", - "euclid", - "lopdf", - "postscript", - "type1-encoding-parser", + "env_logger", + "fast_image_resize", + "fax", + "flate2", + "fontdb", + "getrandom 0.4.3", + "hayro-jbig2", + "hayro-jpeg2000", + "image 0.25.10", + "jpeg-decoder", + "libc", + "log", + "md-5 0.11.0", + "memchr", + "ndarray 0.17.2", + "nom 8.0.0", + "office_oxide", + "phf 0.14.0", + "qcms", + "quick-xml 0.41.0", + "regex", + "rustybuzz", + "serde", + "serde_json", + "sha2 0.11.0", + "smallvec 1.15.1", + "stringprep", + "subsetter", + "taffy", + "thiserror 2.0.18", + "tiny-skia", + "tract-onnx", + "ttf-parser", + "unicode-bidi", + "unicode-linebreak", "unicode-normalization", + "uuid", + "weezl 0.2.1", ] [[package]] @@ -5119,6 +5687,48 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pest" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.108", +] + +[[package]] +name = "pest_meta" +version = "2.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210" +dependencies = [ + "pest", +] + [[package]] name = "phf" version = "0.12.1" @@ -5134,18 +5744,29 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" dependencies = [ - "phf_macros", + "phf_macros 0.13.1", "phf_shared 0.13.1", "serde", ] +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros 0.14.0", + "phf_shared 0.14.0", + "serde", +] + [[package]] name = "phf_codegen" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", ] @@ -5159,19 +5780,42 @@ dependencies = [ "phf_shared 0.13.1", ] +[[package]] +name = "phf_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" +dependencies = [ + "fastrand", + "phf_shared 0.14.0", +] + [[package]] name = "phf_macros" version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", "syn 2.0.108", ] +[[package]] +name = "phf_macros" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" +dependencies = [ + "phf_generator 0.14.0", + "phf_shared 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.108", +] + [[package]] name = "phf_shared" version = "0.12.1" @@ -5190,6 +5834,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "1.1.10" @@ -5268,7 +5921,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.12.0", - "quick-xml", + "quick-xml 0.38.3", "serde", "time", ] @@ -5324,6 +5977,15 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + [[package]] name = "polyval" version = "0.6.2" @@ -5336,12 +5998,6 @@ dependencies = [ "universal-hash", ] -[[package]] -name = "pom" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60f6ce597ecdcc9a098e7fddacb1065093a3d66446fa16c675e7e71d1b5c28e6" - [[package]] name = "portable-atomic" version = "1.11.1" @@ -5357,12 +6013,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postscript" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78451badbdaebaf17f053fd9152b3ffb33b516104eacb45e7864aaa9c712f306" - [[package]] name = "potential_utf" version = "0.1.4" @@ -5395,6 +6045,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -5480,6 +6139,29 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "prost" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b82eaa1d779e9a4bc1c3217db8ffbeabaae1dca241bf70183242128d48681cd" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d2d8d10f3c6ded6da8b05b5fb3b8a5082514344d56c9f871412d29b4e075b4" +dependencies = [ + "anyhow", + "itertools 0.10.5", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "psl-types" version = "2.0.11" @@ -5527,6 +6209,24 @@ dependencies = [ "unicase", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qcms" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edecfcd5d755a5e5d98e24cf43113e7cdaec5a070edd0f6b250c03a573da30fa" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.3" @@ -5536,6 +6236,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5705,12 +6415,6 @@ dependencies = [ "rand 0.8.6", ] -[[package]] -name = "rangemap" -version = "1.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f93e7e49bb0bf967717f7bd674458b3d6b0c5f48ec7e3038166026a69fc22223" - [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5743,6 +6447,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -6022,7 +6736,7 @@ dependencies = [ "hyper", "hyper-util", "oauth2", - "pastey", + "pastey 0.2.3", "pin-project-lite", "process-wrap", "reqwest 0.13.2", @@ -6052,13 +6766,19 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rsa" version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", + "const-oid 0.9.6", "digest 0.10.7", "num-bigint-dig", "num-integer", @@ -6113,13 +6833,27 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rusticata-macros" version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf0c4a6ece9950b9abdb62b1cfcf2a68b3b67a10ba445b3bb85be2a293d0632" dependencies = [ - "nom", + "nom 7.1.3", ] [[package]] @@ -6217,12 +6951,40 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.10.0", + "bytemuck", + "core_maths", + "log", + "smallvec 1.15.1", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + [[package]] name = "ryu" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "safetensors" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172dd94c5a87b5c79f945c863da53b2ebc7ccef4eca24ac63cca66a41aab2178" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "same-file" version = "1.0.6" @@ -6232,6 +6994,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "scan_fmt" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b53b0a5db882a8e2fdaae0a43f7b39e7e9082389e978398bdf223a55b581248" +dependencies = [ + "regex", +] + [[package]] name = "schannel" version = "0.1.28" @@ -6714,12 +7485,31 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d" +[[package]] +name = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +dependencies = [ + "bytemuck", + "read-fonts", +] + [[package]] name = "slab" version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -6935,7 +7725,7 @@ dependencies = [ "hmac", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "percent-encoding", @@ -6975,7 +7765,7 @@ dependencies = [ "home", "itoa", "log", - "md-5", + "md-5 0.10.6", "memchr", "once_cell", "rand 0.8.6", @@ -7046,6 +7836,29 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string-interner" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07f9fdfdd31a0ff38b59deb401be81b73913d76c9cc5b1aed4e1330a223420b9" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "serde", +] + [[package]] name = "string_cache" version = "0.9.0" @@ -7064,7 +7877,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" dependencies = [ - "phf_generator", + "phf_generator 0.13.1", "phf_shared 0.13.1", "proc-macro2", "quote", @@ -7129,6 +7942,18 @@ dependencies = [ "syn 2.0.108", ] +[[package]] +name = "subsetter" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38803281d1c23166c5ebcb455439a5d2afe711cc909cf88af72448c297756ad6" +dependencies = [ + "kurbo", + "rustc-hash", + "skrifa", + "write-fonts", +] + [[package]] name = "subtle" version = "2.6.1" @@ -7247,6 +8072,18 @@ dependencies = [ "version-compare", ] +[[package]] +name = "taffy" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "340a09581f29809fc0df82a3955501dc7f2a21f887e5d1c13dbe288fe1c0bef4" +dependencies = [ + "arrayvec", + "grid", + "serde", + "slotmap", +] + [[package]] name = "tao" version = "0.35.2" @@ -7631,7 +8468,7 @@ dependencies = [ "tokio", "url", "windows-sys 0.60.2", - "zip", + "zip 4.6.1", ] [[package]] @@ -7805,6 +8642,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl 0.1.10", + "zune-jpeg", +] + [[package]] name = "tiktoken-rs" version = "0.12.0" @@ -7862,6 +8713,32 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tiny-skia" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47ffee5eaaf5527f630fb0e356b90ebdec84d5d18d937c5e440350f88c5a91ea" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.18.1", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca365c3faccca67d06593c5980fa6c57687de727a03131735bb85f01fdeeb9" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + [[package]] name = "tinystr" version = "0.8.2" @@ -8267,6 +9144,159 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tract-core" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f0674154b96abb73f8bdade9e6b6e22fa65f4e13da8d84c0659bd7d3c4739b9" +dependencies = [ + "anyhow", + "anymap3", + "bit-set 0.5.3", + "derive-new", + "downcast-rs", + "dyn-clone", + "lazy_static", + "log", + "maplit", + "ndarray 0.16.1", + "num-complex", + "num-integer", + "num-traits", + "pastey 0.1.1", + "rustfft", + "smallvec 1.15.1", + "tract-data", + "tract-linalg", +] + +[[package]] +name = "tract-data" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0972068b06792ef536df873857854c41109aefa3ad90432e09b0b6549e7523" +dependencies = [ + "anyhow", + "downcast-rs", + "dyn-clone", + "dyn-hash", + "half", + "itertools 0.12.1", + "lazy_static", + "libm", + "maplit", + "ndarray 0.16.1", + "nom 8.0.0", + "nom-language", + "num-integer", + "num-traits", + "parking_lot", + "scan_fmt", + "smallvec 1.15.1", + "string-interner", +] + +[[package]] +name = "tract-hir" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe50ad4a84553a75c2eeba7b705ff32f862d7e4bdb5892397b4560ec59d1627d" +dependencies = [ + "derive-new", + "log", + "tract-core", +] + +[[package]] +name = "tract-linalg" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b582f6ef2dcf32a78f043bbbb93ebc54af247c95cd5d18d5f8b36af47a273e" +dependencies = [ + "byteorder", + "cc", + "derive-new", + "downcast-rs", + "dyn-clone", + "dyn-hash", + "half", + "lazy_static", + "liquid", + "liquid-core", + "liquid-derive", + "log", + "num-traits", + "pastey 0.1.1", + "scan_fmt", + "smallvec 1.15.1", + "time", + "tract-data", + "unicode-normalization", + "walkdir", +] + +[[package]] +name = "tract-nnef" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4c317f94210bdfc0b81913965c7d7439d401527cc8d9bbc85fffbb59b09af5c" +dependencies = [ + "byteorder", + "flate2", + "liquid", + "liquid-core", + "log", + "nom 8.0.0", + "nom-language", + "safetensors", + "serde_json", + "tar", + "tract-core", + "walkdir", +] + +[[package]] +name = "tract-onnx" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30cf71aa3c8a2ca05258ee0750e428cf2d0e8a38781ccd2ab9a0900551684c2b" +dependencies = [ + "bytes", + "derive-new", + "log", + "memmap2", + "num-integer", + "prost", + "smallvec 1.15.1", + "tract-hir", + "tract-nnef", + "tract-onnx-opl", +] + +[[package]] +name = "tract-onnx-opl" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a34a0c0d726b7adc04e3ae956fa1e091ac6a33d4b9bc52ef678108c5445efb7d" +dependencies = [ + "getrandom 0.2.16", + "log", + "rand 0.8.6", + "rand_distr", + "rustfft", + "tract-nnef", +] + +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "tray-icon" version = "0.23.1" @@ -8405,6 +9435,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "tungstenite" version = "0.28.0" @@ -8423,13 +9462,10 @@ dependencies = [ ] [[package]] -name = "type1-encoding-parser" -version = "0.1.0" +name = "typed-path" +version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d6cc09e1a99c7e01f2afe4953789311a1c50baebbdac5b477ecf78e2e92a5b" -dependencies = [ - "pom", -] +checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typeid" @@ -8443,6 +9479,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + [[package]] name = "uds_windows" version = "1.1.0" @@ -8507,6 +9549,18 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + [[package]] name = "unicode-general-category" version = "1.1.0" @@ -8519,6 +9573,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + [[package]] name = "unicode-normalization" version = "0.1.25" @@ -8534,6 +9594,12 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "unicode-script" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "383ad40bb927465ec0ce7720e033cb4ca06912855fc35db31b5755d0de75b1ee" + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -9018,6 +10084,12 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "weezl" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4ca08e5ef825b65b056d9efbd95c8750683f0a6d0466d02e96dc2e4e360f3d2" + [[package]] name = "which" version = "8.0.4" @@ -9639,6 +10711,19 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "write-fonts" +version = "0.48.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb731d4c4d93eacc69a1ad2f270f905788a98e4a3438267bcafbe08d3431c8d8" +dependencies = [ + "font-types", + "indexmap 2.12.0", + "kurbo", + "log", + "read-fonts", +] + [[package]] name = "writeable" version = "0.6.2" @@ -9758,7 +10843,7 @@ dependencies = [ "data-encoding", "der-parser", "lazy_static", - "nom", + "nom 7.1.3", "oid-registry", "rusticata-macros", "thiserror 1.0.69", @@ -9972,6 +11057,38 @@ dependencies = [ "memchr", ] +[[package]] +name = "zip" +version = "8.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" +dependencies = [ + "crc32fast", + "flate2", + "indexmap 2.12.0", + "memchr", + "typed-path", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40990edd51aae2c2b6907af74ffb635029d5788228222c4bb811e9351c0caad3" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" @@ -10000,6 +11117,21 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + [[package]] name = "zvariant" version = "5.8.0" diff --git a/frontend/src-tauri/Cargo.toml b/frontend/src-tauri/Cargo.toml index a57384fb..0e26b713 100644 --- a/frontend/src-tauri/Cargo.toml +++ b/frontend/src-tauri/Cargo.toml @@ -40,8 +40,12 @@ maple-proxy = "0.2.0" tauri-plugin-fs = "2.5.1" anyhow = "1.0" axum = "0.8" -pdf-extract = "0.7" +pdf_oxide = { version = "=0.3.74", features = ["ocr-tract", "rendering"] } +image = { version = "0.25", default-features = false, features = ["jpeg", "png"] } base64 = "0.22" +reqwest = { version = "0.13", features = ["stream"] } +futures-util = "0.3" +sha2 = "0.10" [target.'cfg(any(target_os = "macos", target_os = "windows"))'.dependencies] # TTS dependencies (Supertonic) - desktop only @@ -52,10 +56,7 @@ rand_distr = "0.4" hound = "3.5" unicode-normalization = "0.1" regex = "1.10" -reqwest = { version = "0.13", features = ["stream"] } -futures-util = "0.3" dirs = "6.0" -sha2 = "0.10" [target.'cfg(target_os = "linux")'.dependencies] ort = { version = "2.0.0-rc.10", features = ["load-dynamic"] } @@ -65,10 +66,7 @@ rand_distr = "0.4" hound = "3.5" unicode-normalization = "0.1" regex = "1.10" -reqwest = { version = "0.13", features = ["stream"] } -futures-util = "0.3" dirs = "6.0" -sha2 = "0.10" [target.'cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))'.dependencies] # Pin Goose to an exact official upstream commit. Keep this as a git dependency @@ -96,13 +94,12 @@ rand_distr = "0.4" hound = "3.5" unicode-normalization = "0.1" regex = "1.10" -reqwest = { version = "0.13", features = ["stream"] } -futures-util = "0.3" dirs = "6.0" -sha2 = "0.10" [target.'cfg(target_os = "android")'.dependencies] openssl = { version = "0.10.80", default-features = false, features = ["vendored"] } +rustls = { version = "0.23", default-features = false, features = ["aws_lc_rs", "std", "tls12"] } +webpki-roots = "1" [target.'cfg(target_os = "windows")'.dependencies] # Store the proxy API key in Windows Credential Manager rather than as @@ -110,6 +107,9 @@ openssl = { version = "0.10.80", default-features = false, features = ["vendored keyring = { version = "3", features = ["windows-native"] } windows = { version = "0.62.2", features = ["Win32_System_Threading"] } +[dev-dependencies] +tempfile = "3" + [patch.crates-io] # Local patch for tao 0.35.2 Android intent crashes: # https://github.com/tauri-apps/tao/issues/1217 diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index e0e886d6..c2b24111 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ use tauri_plugin_deep_link::DeepLinkExt; #[cfg(desktop)] mod agent; mod pdf_extractor; +mod pdf_ocr; mod proxy; // TTS is available on desktop and iOS (not Android) #[cfg(any(desktop, target_os = "ios"))] diff --git a/frontend/src-tauri/src/pdf_extractor.rs b/frontend/src-tauri/src/pdf_extractor.rs index 2b652a24..a905128c 100644 --- a/frontend/src-tauri/src/pdf_extractor.rs +++ b/frontend/src-tauri/src/pdf_extractor.rs @@ -1,6 +1,23 @@ +use crate::pdf_ocr; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; -use pdf_extract::extract_text_from_mem; +use once_cell::sync::Lazy; +use pdf_oxide::extractors::auto::PageKind; +use pdf_oxide::ocr::OcrEngine; +use pdf_oxide::rendering::{self, RenderOptions}; +use pdf_oxide::PdfDocument; use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use tauri::AppHandle; +use tokio::sync::Semaphore; + +const MAX_DOCUMENT_BYTES: usize = 10 * 1024 * 1024; +const MAX_EXTRACTED_TEXT_BYTES: usize = MAX_DOCUMENT_BYTES; +const OCR_RENDER_MAX_DIMENSION: u32 = 2_000; +const MAX_OCR_SOURCE_IMAGE_PIXELS: u64 = 24_000_000; +const MAX_OCR_PAGE_SOURCE_PIXELS: u64 = 64_000_000; +const MAX_OCR_PAGE_IMAGES: usize = 256; +const PDF_PANIC_MESSAGE: &str = "Maple couldn't process this PDF because its parser stopped unexpectedly. The app is still running; try a different PDF."; +static PDF_JOB_SEMAPHORE: Lazy = Lazy::new(|| Semaphore::new(1)); #[derive(Debug, Serialize, Deserialize)] pub struct DocumentData { @@ -14,30 +31,49 @@ pub struct DocumentResponse { pub status: String, } +enum PdfPreflight { + Complete(String), + NeedsOcr { + native_fallback: String, + has_scanned_pages: bool, + }, +} + #[tauri::command] pub async fn extract_document_content( + app: AppHandle, + file_base64: String, + filename: String, + file_type: String, +) -> Result { + extract_document_content_impl(Some(&app), file_base64, filename, file_type).await +} + +async fn extract_document_content_impl( + app: Option<&AppHandle>, file_base64: String, filename: String, file_type: String, ) -> Result { - // Decode base64 file data + // Reject obviously oversized base64 before allocating the decoded buffer. + let max_encoded_size = MAX_DOCUMENT_BYTES.div_ceil(3) * 4 + 4; + if file_base64.len() > max_encoded_size { + return Err("Document too large (max 10MB)".to_string()); + } + let file_bytes = BASE64 .decode(&file_base64) .map_err(|e| format!("Failed to decode base64 file: {e}"))?; + if file_bytes.len() > MAX_DOCUMENT_BYTES { + return Err("Document too large (max 10MB)".to_string()); + } - let text_content = match file_type.as_str() { - "pdf" | "application/pdf" => { - // Extract text from PDF - extract_text_from_mem(&file_bytes) - .map_err(|e| format!("Failed to extract text from PDF: {e}"))? - } + let text_content = match file_type.to_ascii_lowercase().as_str() { + "pdf" | "application/pdf" => extract_pdf(app, file_bytes).await?, "txt" | "text/plain" | "md" | "text/markdown" => { - // For text files, just convert bytes to string String::from_utf8(file_bytes).map_err(|e| format!("Failed to decode text file: {e}"))? } - _ => { - return Err(format!("Unsupported file type: {file_type}")); - } + _ => return Err(format!("Unsupported file type: {file_type}")), }; Ok(DocumentResponse { @@ -49,16 +85,400 @@ pub async fn extract_document_content( }) } +async fn extract_pdf(app: Option<&AppHandle>, file_bytes: Vec) -> Result { + // PDF rendering and OCR can each use substantial memory. Serializing these + // user-initiated jobs keeps concurrent invokes from multiplying that peak, + // particularly on iOS and Android. + let _job_permit = PDF_JOB_SEMAPHORE + .acquire() + .await + .map_err(|_| "Maple's PDF processor is unavailable. Please try again.".to_string())?; + + let preflight_bytes = file_bytes.clone(); + match run_pdf_job(move || extract_native_or_request_ocr(preflight_bytes)).await? { + PdfPreflight::Complete(text) => ensure_document_has_text(text), + PdfPreflight::NeedsOcr { + native_fallback, + has_scanned_pages, + } => { + let Some(app) = app else { + if has_scanned_pages { + return Err("This scanned PDF needs Maple's on-device OCR models.".to_string()); + } + return ensure_document_has_text(native_fallback); + }; + let engine = match pdf_ocr::get_or_prepare_engine(app).await { + Ok(engine) => engine, + Err(error) if !has_scanned_pages => { + log::warn!( + "Optional PDF OCR enrichment is unavailable; using native text: {error}" + ); + return ensure_document_has_text(native_fallback); + } + Err(error) => return Err(error), + }; + match run_pdf_job(move || extract_pdf_with_ocr(file_bytes, engine)).await { + Ok(text) => ensure_document_has_text(text), + Err(error) if !has_scanned_pages => { + log::warn!("Optional PDF OCR enrichment failed; using native text: {error}"); + ensure_document_has_text(native_fallback) + } + Err(error) => Err(error), + } + } + } +} + +async fn run_pdf_job(operation: F) -> Result +where + T: Send + 'static, + F: FnOnce() -> Result + Send + 'static, +{ + match tokio::task::spawn_blocking(operation).await { + Ok(result) => result, + Err(error) if error.is_panic() => { + log::error!("PDF processing panicked inside its isolated worker: {error}"); + Err(PDF_PANIC_MESSAGE.to_string()) + } + Err(error) => { + log::error!("PDF processing worker could not complete: {error}"); + Err("Maple couldn't finish processing this PDF. Please try again.".to_string()) + } + } +} + +fn open_pdf(file_bytes: Vec) -> Result { + let document = PdfDocument::from_bytes(file_bytes) + .map_err(|e| format!("Maple couldn't read this PDF: {e}"))?; + if document.is_encrypted() && !document.is_authenticated() { + return Err( + "This PDF is password-protected. Maple cannot read protected PDFs yet.".to_string(), + ); + } + Ok(document) +} + +fn extract_native_or_request_ocr(file_bytes: Vec) -> Result { + let document = open_pdf(file_bytes)?; + let page_count = document + .page_count() + .map_err(|e| format!("Maple couldn't read the PDF's page list: {e}"))?; + + let mut pages = Vec::with_capacity(page_count); + let mut extracted_bytes = 0; + let mut needs_ocr = false; + let mut has_scanned_pages = false; + for page in 0..page_count { + let classification = document + .classify_page(page) + .map_err(|e| format!("Maple couldn't inspect PDF page {}: {e}", page + 1))?; + let text = match classification.kind { + PageKind::TextLayer => document + .extract_text(page) + .map_err(|e| format!("Maple couldn't extract PDF page {}: {e}", page + 1))?, + PageKind::Empty => String::new(), + PageKind::Scanned => { + needs_ocr = true; + has_scanned_pages = true; + extract_native_best_effort(&document, page) + } + PageKind::ImageText | PageKind::Mixed => { + needs_ocr = true; + extract_native_best_effort(&document, page) + } + _ => { + return Err(format!( + "PDF page {} uses a page type this Maple version does not support.", + page + 1 + )); + } + }; + push_page_with_budget(&mut pages, &mut extracted_bytes, text)?; + } + + let native_text = join_pages(pages); + if needs_ocr { + Ok(PdfPreflight::NeedsOcr { + native_fallback: native_text, + has_scanned_pages, + }) + } else { + Ok(PdfPreflight::Complete(native_text)) + } +} + +fn extract_pdf_with_ocr(file_bytes: Vec, engine: Arc) -> Result { + let document = open_pdf(file_bytes)?; + let page_count = document + .page_count() + .map_err(|e| format!("Maple couldn't read the PDF's page list: {e}"))?; + let mut pages = Vec::with_capacity(page_count); + let mut extracted_bytes = 0; + let mut first_required_ocr_error = None; + + for page in 0..page_count { + let classification = document + .classify_page(page) + .map_err(|e| format!("Maple couldn't inspect PDF page {}: {e}", page + 1))?; + let text = match classification.kind { + PageKind::TextLayer => document + .extract_text(page) + .map_err(|e| format!("Maple couldn't extract PDF page {}: {e}", page + 1))?, + PageKind::Empty => String::new(), + PageKind::Scanned => { + let native = extract_native_best_effort(&document, page); + match ocr_page(&document, page, &engine) { + Ok(fragments) => merge_native_and_ocr(&native, &fragments), + Err(error) => { + log::warn!("Required OCR failed on PDF page {}: {error}", page + 1); + if first_required_ocr_error.is_none() { + first_required_ocr_error = Some(error); + } + native + } + } + } + PageKind::ImageText | PageKind::Mixed => { + let native_result = document + .extract_text(page) + .map_err(|e| format!("Maple couldn't extract PDF page {}: {e}", page + 1)); + let ocr_result = ocr_page(&document, page, &engine); + match (native_result, ocr_result) { + (Ok(native), Ok(fragments)) => merge_native_and_ocr(&native, &fragments), + (Ok(native), Err(error)) => { + log::warn!( + "Optional OCR enrichment failed on PDF page {}: {error}", + page + 1 + ); + native + } + (Err(native_error), Ok(fragments)) if !fragments.is_empty() => { + log::warn!( + "Native extraction failed on PDF page {}; using OCR: {native_error}", + page + 1 + ); + merge_native_and_ocr("", &fragments) + } + (Err(native_error), Ok(_)) => return Err(native_error), + (Err(native_error), Err(ocr_error)) => { + return Err(format!( + "{native_error}; on-device OCR also failed: {ocr_error}" + )); + } + } + } + _ => { + return Err(format!( + "PDF page {} uses a page type this Maple version does not support.", + page + 1 + )); + } + }; + push_page_with_budget(&mut pages, &mut extracted_bytes, text)?; + } + + let text = join_pages(pages); + if text.trim().is_empty() { + if let Some(error) = first_required_ocr_error { + return Err(error); + } + } + Ok(text) +} + +fn ocr_page( + document: &PdfDocument, + page: usize, + engine: &OcrEngine, +) -> Result, String> { + validate_ocr_page_resources(document, page)?; + + // Always OCR one bounded rendering of the complete page. This captures + // tiled scans, inline images, vector text, and multiple image regions + // without PDFOxide's eager all-image decode or largest-image truncation. + let rendered = rendering::render_page_fit( + document, + page, + OCR_RENDER_MAX_DIMENSION, + OCR_RENDER_MAX_DIMENSION, + &RenderOptions::default().as_raw(), + ) + .map_err(|e| format!("Maple couldn't render PDF page {} for OCR: {e}", page + 1))?; + let rgba = image::RgbaImage::from_raw(rendered.width, rendered.height, rendered.data) + .ok_or_else(|| format!("Maple couldn't decode PDF page {} for OCR.", page + 1))?; + let image = image::DynamicImage::ImageRgba8(rgba); + let output = engine + .ocr_image(&image) + .map_err(|e| format!("On-device OCR failed on PDF page {}: {e}", page + 1))?; + + let mut spans = output.spans; + spans.sort_by(|left, right| { + const Y_BAND: f32 = 10.0; + let left_band = (left.polygon[0][1] / Y_BAND).round() as i64; + let right_band = (right.polygon[0][1] / Y_BAND).round() as i64; + left_band + .cmp(&right_band) + .then_with(|| left.polygon[0][0].total_cmp(&right.polygon[0][0])) + .then_with(|| left.polygon[0][1].total_cmp(&right.polygon[0][1])) + }); + Ok(spans + .into_iter() + .map(|span| span.text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect()) +} + +fn validate_ocr_page_resources(document: &PdfDocument, page: usize) -> Result<(), String> { + let handles = document.page_image_handles(page).map_err(|e| { + format!( + "Maple couldn't inspect images on PDF page {}: {e}", + page + 1 + ) + })?; + validate_ocr_source_budget( + page, + handles + .iter() + .map(|image| (image.width, image.height, image.byte_size_compressed)), + ) +} + +fn validate_ocr_source_budget( + page: usize, + images: impl IntoIterator, +) -> Result<(), String> { + let mut count = 0_usize; + let mut total_pixels = 0_u64; + for (width, height, compressed_bytes) in images { + count += 1; + let pixels = u64::from(width) + .checked_mul(u64::from(height)) + .ok_or_else(|| safe_ocr_complexity_error(page))?; + total_pixels = total_pixels + .checked_add(pixels) + .ok_or_else(|| safe_ocr_complexity_error(page))?; + if count > MAX_OCR_PAGE_IMAGES + || pixels > MAX_OCR_SOURCE_IMAGE_PIXELS + || total_pixels > MAX_OCR_PAGE_SOURCE_PIXELS + || compressed_bytes > MAX_DOCUMENT_BYTES as u64 + { + return Err(safe_ocr_complexity_error(page)); + } + } + Ok(()) +} + +fn safe_ocr_complexity_error(page: usize) -> String { + format!( + "PDF page {} is too complex for Maple to OCR safely. Try a lower-resolution PDF.", + page + 1 + ) +} + +fn extract_native_best_effort(document: &PdfDocument, page: usize) -> String { + document.extract_text(page).unwrap_or_else(|error| { + log::warn!( + "Native text extraction failed on OCR-routed PDF page {}: {error}", + page + 1 + ); + String::new() + }) +} + +// Compare individual OCR detection spans, rather than the engine's single +// space-joined page string, so one novel image caption does not duplicate an +// otherwise native-readable page. +fn merge_native_and_ocr(native: &str, ocr_fragments: &[String]) -> String { + let native_trimmed = native.trim_end(); + if ocr_fragments.is_empty() { + return native.to_string(); + } + if native_trimmed.trim().is_empty() { + return ocr_fragments.join("\n"); + } + + let normalize = |value: &str| { + value + .split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() + }; + let native_normalized = normalize(native_trimmed); + let mut extras: Vec<&str> = Vec::new(); + for fragment in ocr_fragments { + let fragment = fragment.trim(); + let normalized = normalize(fragment); + if normalized.is_empty() + || native_normalized.contains(&normalized) + || extras.iter().any(|extra| normalize(extra) == normalized) + { + continue; + } + extras.push(fragment); + } + + if extras.is_empty() { + native.to_string() + } else { + format!("{native_trimmed}\n{}", extras.join("\n")) + } +} + +fn push_page_with_budget( + pages: &mut Vec, + extracted_bytes: &mut usize, + text: String, +) -> Result<(), String> { + *extracted_bytes = extracted_bytes + .checked_add(text.len().saturating_add(2)) + .ok_or_else(extracted_text_too_large)?; + if *extracted_bytes > MAX_EXTRACTED_TEXT_BYTES { + return Err(extracted_text_too_large()); + } + pages.push(text); + Ok(()) +} + +fn extracted_text_too_large() -> String { + "The extracted text from this PDF is too large for Maple (max 10MB).".to_string() +} + +fn join_pages(pages: Vec) -> String { + pages + .into_iter() + .map(|page| page.trim().to_string()) + .collect::>() + .join("\n\n") + .trim() + .to_string() +} + +fn ensure_document_has_text(text: String) -> Result { + if text.trim().is_empty() { + Err("This PDF does not contain text Maple can read.".to_string()) + } else { + Ok(text) + } +} + #[cfg(test)] mod tests { - use super::extract_document_content; + use super::{ + extract_document_content_impl, extract_pdf_with_ocr, merge_native_and_ocr, run_pdf_job, + validate_ocr_source_budget, PDF_PANIC_MESSAGE, + }; use base64::{engine::general_purpose::STANDARD as BASE64, Engine}; + use pdf_oxide::ocr::{OcrConfig, OcrEngine}; + use std::path::PathBuf; + use std::sync::Arc; #[tokio::test] async fn extract_document_content_text_plain_success() { let file_base64 = BASE64.encode(b"Hello, Maple!"); - let resp = extract_document_content( + let resp = extract_document_content_impl( + None, file_base64, "hello.txt".to_string(), "text/plain".to_string(), @@ -72,11 +492,95 @@ mod tests { } #[tokio::test] - async fn extract_document_content_rejects_unsupported_file_type() { - let file_base64 = BASE64.encode(b"whatever"); + async fn extracts_native_pdf_without_ocr_models() { + let file_base64 = BASE64.encode(native_text_pdf("MAPLE NATIVE PDF")); - let err = extract_document_content( + let resp = extract_document_content_impl( + None, file_base64, + "native.pdf".to_string(), + "application/pdf".to_string(), + ) + .await + .expect("native PDF should not require OCR models"); + + assert!(resp.document.text_content.contains("MAPLE NATIVE PDF")); + } + + #[tokio::test] + async fn native_readable_hybrid_pdf_does_not_require_ocr_models() { + let pdf = native_text_and_image_pdf("MAPLE HYBRID PDF KEEPS ITS NATIVE TEXT OFFLINE"); + let document = pdf_oxide::PdfDocument::from_bytes(pdf.clone()).expect("open hybrid PDF"); + let classification = document.classify_page(0).expect("classify hybrid PDF"); + assert!(matches!( + classification.kind, + pdf_oxide::extractors::auto::PageKind::ImageText + | pdf_oxide::extractors::auto::PageKind::Mixed + )); + + let response = extract_document_content_impl( + None, + BASE64.encode(pdf), + "hybrid.pdf".to_string(), + "application/pdf".to_string(), + ) + .await + .expect("native-readable hybrid PDF should work without OCR models"); + + assert!(response.document.text_content.contains("MAPLE HYBRID PDF")); + } + + #[tokio::test] + async fn invalid_pdf_returns_a_normal_error() { + let error = extract_document_content_impl( + None, + BASE64.encode(b"not a PDF"), + "invalid.pdf".to_string(), + "pdf".to_string(), + ) + .await + .expect_err("invalid PDF should fail"); + + assert!(error.contains("couldn't read this PDF"), "{error}"); + } + + #[tokio::test] + async fn type4_tint_transform_reproducer_returns_without_a_parser_panic() { + // Minimal public reproducer for the pdf-extract FunctionType 4 crash + // that prompted this migration. It contains no text, so a normal + // empty-document error is the expected result. + const TYPE4_PDF: &str = "JVBERi0xLjcKMSAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbNCAwIFJdL0NvdW50IDE+PgplbmRvYmoKMiAwIG9iago8PC9GdW5jdGlvblR5cGUgNC9Eb21haW5bMCAxXS9SYW5nZVswIDEgMCAxIDAgMV0vTGVuZ3RoIDExPj5zdHJlYW0KeyBkdXAgZHVwIH0KZW5kc3RyZWFtIAplbmRvYmoKMyAwIG9iago8PC9MZW5ndGggOD4+c3RyZWFtCi9DUzEgY3MKCmVuZHN0cmVhbSAKZW5kb2JqCjQgMCBvYmoKPDwvVHlwZS9QYWdlL1BhcmVudCAxIDAgUi9NZWRpYUJveFswIDAgMjAwIDIwMF0vUmVzb3VyY2VzPDwvQ29sb3JTcGFjZTw8L0NTMVsvU2VwYXJhdGlvbi9TcG90L0RldmljZVJHQiAyIDAgUl0+Pj4+L0NvbnRlbnRzIDMgMCBSPj4KZW5kb2JqCjUgMCBvYmoKPDwvVHlwZS9DYXRhbG9nL1BhZ2VzIDEgMCBSPj4KZW5kb2JqCjYgMCBvYmoKPDwvUm9vdCA1IDAgUi9UeXBlL1hSZWYvU2l6ZSA3L1dbMSA0IDJdL0luZGV4WzEgNl0vTGVuZ3RoIDQyPj5zdHJlYW0KAQAAAAkAAAEAAAA8AAABAAAApQAAAQAAANwAAAEAAAFvAAABAAABnAAACmVuZHN0cmVhbSAKZW5kb2JqCgpzdGFydHhyZWYKNDEyCiUlRU9G"; + + let error = extract_document_content_impl( + None, + TYPE4_PDF.to_string(), + "type4.pdf".to_string(), + "application/pdf".to_string(), + ) + .await + .expect_err("text-free reproducer should return a normal error"); + + assert_ne!(error, PDF_PANIC_MESSAGE); + } + + #[tokio::test] + async fn parser_panic_is_contained_and_a_later_job_still_runs() { + let panic_error = run_pdf_job::<(), _>(|| panic!("synthetic parser panic")) + .await + .expect_err("panic should become an ordinary error"); + assert_eq!(panic_error, PDF_PANIC_MESSAGE); + + let result = run_pdf_job(|| Ok::<_, String>("worker recovered".to_string())) + .await + .expect("a later worker should still run"); + assert_eq!(result, "worker recovered"); + } + + #[tokio::test] + async fn extract_document_content_rejects_unsupported_file_type() { + let err = extract_document_content_impl( + None, + BASE64.encode(b"whatever"), "file.bin".to_string(), "application/octet-stream".to_string(), ) @@ -91,7 +595,8 @@ mod tests { #[tokio::test] async fn extract_document_content_rejects_invalid_base64() { - let err = extract_document_content( + let err = extract_document_content_impl( + None, "not base64".to_string(), "file.txt".to_string(), "txt".to_string(), @@ -107,15 +612,126 @@ mod tests { #[tokio::test] async fn extract_document_content_rejects_invalid_utf8_for_text_files() { - let file_base64 = BASE64.encode([0xff, 0xfe, 0xfd]); - - let err = extract_document_content(file_base64, "bad.txt".to_string(), "txt".to_string()) - .await - .expect_err("expected invalid utf-8 to error"); + let err = extract_document_content_impl( + None, + BASE64.encode([0xff, 0xfe, 0xfd]), + "bad.txt".to_string(), + "txt".to_string(), + ) + .await + .expect_err("expected invalid utf-8 to error"); assert!( err.contains("Failed to decode text file"), "unexpected error: {err}" ); } + + #[test] + fn hybrid_merge_deduplicates_native_lines_and_keeps_image_text() { + assert_eq!( + merge_native_and_ocr( + "CONFIDENTIAL QUARTERLY MEMO 2026\nNative line", + &[ + "confidential quarterly memo 2026".to_string(), + "IMAGE CAPTION".to_string(), + ] + ), + "CONFIDENTIAL QUARTERLY MEMO 2026\nNative line\nIMAGE CAPTION" + ); + } + + #[test] + fn ocr_source_budget_rejects_unsafe_page_allocations() { + assert!(validate_ocr_source_budget(0, [(4_000, 6_000, 1_000)]).is_ok()); + assert!(validate_ocr_source_budget(0, [(6_000, 6_000, 1_000)]).is_err()); + assert!(validate_ocr_source_budget( + 0, + [ + (4_000, 4_000, 1_000), + (4_000, 4_000, 1_000), + (4_000, 4_000, 1_000), + (4_000, 4_000, 1_000), + (1, 1, 1_000) + ] + ) + .is_err()); + } + + #[tokio::test] + #[ignore = "set MAPLE_OCR_TEST_PDF and MAPLE_OCR_MODEL_DIR to run model-backed OCR"] + async fn extracts_scanned_pdf_with_real_ocr_models() { + let pdf_path = PathBuf::from( + std::env::var("MAPLE_OCR_TEST_PDF").expect("MAPLE_OCR_TEST_PDF is required"), + ); + let model_dir = PathBuf::from( + std::env::var("MAPLE_OCR_MODEL_DIR").expect("MAPLE_OCR_MODEL_DIR is required"), + ); + let pdf = std::fs::read(pdf_path).expect("read OCR PDF fixture"); + let engine = OcrEngine::new( + model_dir.join("det.onnx"), + model_dir.join("rec.onnx"), + model_dir.join("en_dict.txt"), + OcrConfig::default(), + ) + .map(Arc::new) + .expect("load OCR models"); + + let text = run_pdf_job(move || extract_pdf_with_ocr(pdf, engine)) + .await + .expect("OCR extraction should succeed"); + let uppercase = text.to_uppercase(); + assert!(!text.trim().is_empty()); + assert!( + uppercase.contains("OCR") || uppercase.contains("TEST") || uppercase.contains("HELLO"), + "unexpected OCR output: {text:?}" + ); + } + + fn native_text_pdf(text: &str) -> Vec { + let content = format!("BT /F1 18 Tf 72 720 Td ({text}) Tj ET"); + build_pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + ]) + } + + fn native_text_and_image_pdf(text: &str) -> Vec { + let content = + format!("q 200 0 0 200 72 400 cm /Im1 Do Q BT /F1 18 Tf 72 720 Td ({text}) Tj ET"); + build_pdf(&[ + "<< /Type /Catalog /Pages 2 0 R >>".to_string(), + "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_string(), + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> /XObject << /Im1 6 0 R >> >> /Contents 5 0 R >>".to_string(), + "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".to_string(), + format!("<< /Length {} >>\nstream\n{content}\nendstream", content.len()), + "<< /Type /XObject /Subtype /Image /Width 300 /Height 300 /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /ASCIIHexDecode /Length 7 >>\nstream\nFF0000>\nendstream".to_string(), + ]) + } + + fn build_pdf(objects: &[String]) -> Vec { + let mut pdf = b"%PDF-1.4\n".to_vec(); + let mut offsets = Vec::new(); + for (index, object) in objects.iter().enumerate() { + offsets.push(pdf.len()); + pdf.extend_from_slice(format!("{} 0 obj\n{object}\nendobj\n", index + 1).as_bytes()); + } + let xref_offset = pdf.len(); + pdf.extend_from_slice(format!("xref\n0 {}\n", objects.len() + 1).as_bytes()); + pdf.extend_from_slice(b"0000000000 65535 f \n"); + for offset in offsets { + pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + pdf.extend_from_slice( + format!( + "trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{xref_offset}\n%%EOF\n", + objects.len() + 1 + ) + .as_bytes(), + ); + pdf + } } diff --git a/frontend/src-tauri/src/pdf_ocr.rs b/frontend/src-tauri/src/pdf_ocr.rs new file mode 100644 index 00000000..ca1110ff --- /dev/null +++ b/frontend/src-tauri/src/pdf_ocr.rs @@ -0,0 +1,320 @@ +use futures_util::StreamExt; +use once_cell::sync::{Lazy, OnceCell}; +use pdf_oxide::ocr::{OcrConfig, OcrEngine}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::fs::{self, File}; +use std::io::{Read, Write}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tauri::{AppHandle, Emitter, Manager}; + +const OCR_MODEL_PACK_VERSION: &str = "paddleocr-en-v1"; +const TOTAL_MODEL_SIZE: u64 = 12_577_821; + +struct ModelFile { + name: &'static str, + url: &'static str, + size: u64, + sha256: &'static str, +} + +// These immutable revisions are intentionally independent from PDFOxide's +// mutable `resolve/main` model downloader. See docs/pdf-ocr.md. +const MODEL_FILES: &[ModelFile] = &[ + ModelFile { + name: "det.onnx", + url: "https://huggingface.co/SWHL/RapidOCR/resolve/1cfba2e90fc938db55889873735088de210cc173/PP-OCRv4/ch_PP-OCRv4_det_infer.onnx", + size: 4_745_517, + sha256: "d2a7720d45a54257208b1e13e36a8479894cb74155a5efe29462512d42f49da9", + }, + ModelFile { + name: "rec.onnx", + url: "https://huggingface.co/monkt/paddleocr-onnx/resolve/7b02d0a30a07ba2b92ad1ff5a8941ae2c633de65/languages/english/rec.onnx", + size: 7_830_888, + sha256: "4e16deb22c4da6468bdca539b2cd3c8687825538b67109177c47d359ab994cd7", + }, + ModelFile { + name: "en_dict.txt", + url: "https://huggingface.co/monkt/paddleocr-onnx/resolve/7b02d0a30a07ba2b92ad1ff5a8941ae2c633de65/languages/english/dict.txt", + size: 1_416, + sha256: "e025a66d31f327ba0c232e03f407ae8d105e1e709e7ccb3f408aa778c24e70d6", + }, +]; + +static OCR_ENGINE: OnceCell> = OnceCell::new(); +static OCR_SETUP_LOCK: Lazy> = Lazy::new(|| tokio::sync::Mutex::new(())); + +#[derive(Clone, Serialize)] +struct OcrDownloadProgress { + downloaded: u64, + total: u64, + file_name: String, + percent: f64, +} + +pub async fn get_or_prepare_engine(app: &AppHandle) -> Result, String> { + if let Some(engine) = OCR_ENGINE.get() { + return Ok(engine.clone()); + } + + let _setup_guard = OCR_SETUP_LOCK.lock().await; + if let Some(engine) = OCR_ENGINE.get() { + return Ok(engine.clone()); + } + + let models_dir = app + .path() + .app_cache_dir() + .map_err(|e| format!("Failed to locate Maple's OCR cache: {e}"))? + .join("ocr") + .join("models") + .join(OCR_MODEL_PACK_VERSION); + + ensure_models(app, &models_dir).await.map_err(|e| { + log::error!("OCR model setup failed: {e}"); + format!( + "Maple couldn't download its on-device OCR models. Check your connection and try the PDF again. ({e})" + ) + })?; + + let det_path = models_dir.join("det.onnx"); + let rec_path = models_dir.join("rec.onnx"); + let dict_path = models_dir.join("en_dict.txt"); + let engine = tokio::task::spawn_blocking(move || { + OcrEngine::new(det_path, rec_path, dict_path, OcrConfig::default()) + .map(Arc::new) + .map_err(|e| format!("Failed to initialize the on-device OCR engine: {e}")) + }) + .await + .map_err(|e| format!("The on-device OCR engine stopped unexpectedly: {e}"))??; + + // Another task cannot race this initialization because OCR_SETUP_LOCK is held. + let _ = OCR_ENGINE.set(engine.clone()); + Ok(engine) +} + +async fn ensure_models(app: &AppHandle, models_dir: &Path) -> Result<(), String> { + fs::create_dir_all(models_dir) + .map_err(|e| format!("Failed to create the OCR model cache: {e}"))?; + + let client = configure_download_tls(reqwest::Client::builder())? + .connect_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(300)) + .build() + .map_err(|e| format!("Failed to create the OCR download client: {e}"))?; + + let mut completed = 0; + for model in MODEL_FILES { + let path = models_dir.join(model.name); + if verify_model_file(&path, model)? { + completed += model.size; + emit_progress(app, model.name, completed); + continue; + } + + if path.exists() { + fs::remove_file(&path) + .map_err(|e| format!("Failed to replace invalid {}: {e}", model.name))?; + } + + download_model(app, &client, models_dir, model, completed).await?; + completed += model.size; + } + + Ok(()) +} + +#[cfg(target_os = "android")] +fn configure_download_tls( + builder: reqwest::ClientBuilder, +) -> Result { + // Reqwest's platform verifier needs separate Kotlin/JNI initialization on + // Android. This download-only client instead uses the standard Mozilla root + // set already supported by rustls, keeping the mobile integration in Rust. + let mut roots = rustls::RootCertStore::empty(); + roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned()); + let provider = Arc::new(rustls::crypto::aws_lc_rs::default_provider()); + let tls = rustls::ClientConfig::builder_with_provider(provider) + .with_safe_default_protocol_versions() + .map_err(|e| format!("Failed to configure Android OCR download TLS: {e}"))? + .with_root_certificates(roots) + .with_no_client_auth(); + Ok(builder.tls_backend_preconfigured(tls)) +} + +#[cfg(not(target_os = "android"))] +fn configure_download_tls( + builder: reqwest::ClientBuilder, +) -> Result { + Ok(builder) +} + +async fn download_model( + app: &AppHandle, + client: &reqwest::Client, + models_dir: &Path, + model: &ModelFile, + already_downloaded: u64, +) -> Result<(), String> { + let final_path = models_dir.join(model.name); + let temp_path = models_dir.join(format!("{}.part", model.name)); + let _ = fs::remove_file(&temp_path); + + log::info!("Downloading pinned OCR model {}", model.name); + let result = async { + let response = client + .get(model.url) + .send() + .await + .map_err(|e| format!("Failed to download {}: {e}", model.name))?; + if !response.status().is_success() { + return Err(format!( + "Failed to download {}: HTTP {}", + model.name, + response.status() + )); + } + if let Some(length) = response.content_length() { + if length != model.size { + return Err(format!( + "Unexpected Content-Length for {}: expected {}, got {length}", + model.name, model.size + )); + } + } + + let mut file = File::create(&temp_path) + .map_err(|e| format!("Failed to create {}: {e}", model.name))?; + let mut stream = response.bytes_stream(); + let mut downloaded = 0_u64; + let mut hasher = Sha256::new(); + + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|e| format!("Download failed for {}: {e}", model.name))?; + downloaded = downloaded + .checked_add(chunk.len() as u64) + .ok_or_else(|| format!("Download size overflow for {}", model.name))?; + if downloaded > model.size { + return Err(format!( + "Download for {} exceeded its pinned size", + model.name + )); + } + file.write_all(&chunk) + .map_err(|e| format!("Failed to write {}: {e}", model.name))?; + hasher.update(&chunk); + emit_progress(app, model.name, already_downloaded + downloaded); + } + + if downloaded != model.size { + return Err(format!( + "Incomplete download for {}: expected {}, got {downloaded}", + model.name, model.size + )); + } + let actual_hash = format!("{:x}", hasher.finalize()); + if actual_hash != model.sha256 { + return Err(format!("Checksum verification failed for {}", model.name)); + } + + file.flush() + .map_err(|e| format!("Failed to flush {}: {e}", model.name))?; + file.sync_all() + .map_err(|e| format!("Failed to sync {}: {e}", model.name))?; + drop(file); + fs::rename(&temp_path, &final_path) + .map_err(|e| format!("Failed to finalize {}: {e}", model.name))?; + Ok(()) + } + .await; + + if result.is_err() { + let _ = fs::remove_file(&temp_path); + } + result +} + +fn verify_model_file(path: &Path, model: &ModelFile) -> Result { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(false), + Err(error) => { + return Err(format!( + "Failed to inspect cached model {}: {error}", + model.name + )); + } + }; + if !metadata.is_file() || metadata.len() != model.size { + return Ok(false); + } + + let actual_hash = sha256_file(path) + .map_err(|e| format!("Failed to verify cached model {}: {e}", model.name))?; + Ok(actual_hash == model.sha256) +} + +fn sha256_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut hasher = Sha256::new(); + let mut buffer = [0_u8; 64 * 1024]; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(format!("{:x}", hasher.finalize())) +} + +fn emit_progress(app: &AppHandle, file_name: &str, downloaded: u64) { + let downloaded = downloaded.min(TOTAL_MODEL_SIZE); + let _ = app.emit( + "ocr-download-progress", + OcrDownloadProgress { + downloaded, + total: TOTAL_MODEL_SIZE, + file_name: file_name.to_string(), + percent: downloaded as f64 / TOTAL_MODEL_SIZE as f64 * 100.0, + }, + ); +} + +#[cfg(test)] +mod tests { + use super::{sha256_file, verify_model_file, ModelFile}; + use std::fs; + + #[test] + fn model_verification_checks_size_and_sha256() { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("tiny-model"); + fs::write(&path, b"maple").expect("write fixture"); + let model = ModelFile { + name: "tiny-model", + url: "https://example.invalid/tiny-model", + size: 5, + sha256: "49ead2b1066bda1e127f6ae0bd163778d08587e3e97d9ed58e8cc99972460a1c", + }; + + assert!(verify_model_file(&path, &model).expect("verify model")); + + fs::write(&path, b"Maple").expect("replace fixture"); + assert!(!verify_model_file(&path, &model).expect("reject bad hash")); + } + + #[test] + fn sha256_file_uses_lowercase_hex() { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("hash-input"); + fs::write(&path, b"maple").expect("write fixture"); + + assert_eq!( + sha256_file(&path).expect("hash fixture"), + "49ead2b1066bda1e127f6ae0bd163778d08587e3e97d9ed58e8cc99972460a1c" + ); + } +} diff --git a/frontend/src/components/UnifiedChat.tsx b/frontend/src/components/UnifiedChat.tsx index 04cb3a8e..590ad5e1 100644 --- a/frontend/src/components/UnifiedChat.tsx +++ b/frontend/src/components/UnifiedChat.tsx @@ -32,6 +32,11 @@ import { maybeReadLinuxTauriClipboardImages } from "@/utils/imagePaste"; import { truncateMarkdownPreservingLinks } from "@/utils/markdown"; +import { + getDocumentProcessingErrorMessage, + getSupportedDocumentType, + prepareExtractedPdfText +} from "@/utils/documentUpload"; import { useOpenAI } from "@/ai/useOpenAi"; import { DEFAULT_MODEL_ID, getInitialWebSearchEnabled } from "@/state/LocalStateContext"; import { Markdown, ThinkingBlock } from "@/components/markdown"; @@ -1470,6 +1475,7 @@ export function UnifiedChat() { const fileInputRef = useRef(null); const documentInputRef = useRef(null); const imagePasteGenerationRef = useRef(0); + const documentUploadGenerationRef = useRef(0); // Refs const textareaRef = useRef(null); @@ -1481,12 +1487,14 @@ export function UnifiedChat() { // Attachment cleanup function - defined early to avoid reference errors const clearAllAttachments = useCallback(() => { imagePasteGenerationRef.current += 1; + documentUploadGenerationRef.current += 1; // Clean up image URLs imageUrls.forEach((url) => URL.revokeObjectURL(url)); setImageUrls(new Map()); setDraftImages([]); setDocumentText(""); setDocumentName(""); + setIsProcessingDocument(false); setAttachmentError(null); }, [imageUrls]); @@ -2348,11 +2356,15 @@ export function UnifiedChat() { setIsProcessingDocument(true); setAttachmentError(null); + const uploadGeneration = ++documentUploadGenerationRef.current; try { + const documentType = getSupportedDocumentType(file.name); + // For text files, read directly - if (file.name.endsWith(".txt") || file.name.endsWith(".md")) { + if (documentType === "txt" || documentType === "md") { const text = await file.text(); + if (uploadGeneration !== documentUploadGenerationRef.current) return; // Format as JSON for consistency with PDF handling const documentData = { document: { @@ -2362,7 +2374,7 @@ export function UnifiedChat() { }; setDocumentText(JSON.stringify(documentData)); setDocumentName(file.name); - } else if (file.name.endsWith(".pdf") && isTauriEnv) { + } else if (documentType === "pdf" && isTauriEnv) { // For PDFs in Tauri, use the parseDocument API const reader = new FileReader(); const base64Data = await new Promise((resolve, reject) => { @@ -2391,30 +2403,39 @@ export function UnifiedChat() { filename: file.name, fileType: "pdf" }); + if (uploadGeneration !== documentUploadGenerationRef.current) return; - if (result.document?.text_content) { - // Create a cleaned version with image references removed - const cleanedParsed = { - document: { - filename: result.document.filename, - text_content: result.document.text_content.replace(/!\[Image\]\([^)]+\)/g, "") - } - }; - - // Store as JSON string for markdown.tsx to parse and display properly - setDocumentText(JSON.stringify(cleanedParsed)); - setDocumentName(file.name); + const cleanedText = prepareExtractedPdfText(result.document?.text_content); + if (cleanedText === null) { + setAttachmentError("No readable text was found in this PDF"); + setTimeout(() => setAttachmentError(null), 5000); + return; } - } else if (file.name.endsWith(".pdf")) { - setAttachmentError("PDF files can only be processed in the desktop app"); + + const cleanedParsed = { + document: { + filename: result.document.filename, + text_content: cleanedText + } + }; + + // Store as JSON string for markdown.tsx to parse and display properly + setDocumentText(JSON.stringify(cleanedParsed)); + setDocumentName(file.name); + } else if (documentType === "pdf") { + setAttachmentError("PDF files can only be processed in the Maple app"); setTimeout(() => setAttachmentError(null), 5000); } } catch (error) { console.error("Document processing error:", error); - setAttachmentError("Failed to process document"); - setTimeout(() => setAttachmentError(null), 5000); + if (uploadGeneration === documentUploadGenerationRef.current) { + setAttachmentError(getDocumentProcessingErrorMessage(error)); + setTimeout(() => setAttachmentError(null), 5000); + } } finally { - setIsProcessingDocument(false); + if (uploadGeneration === documentUploadGenerationRef.current) { + setIsProcessingDocument(false); + } e.target.value = ""; } }, @@ -2845,7 +2866,7 @@ export function UnifiedChat() { const textToSend = overrideInput || input; const trimmedInput = textToSend.trim(); const hasContent = trimmedInput || draftImages.length > 0 || documentText; - if (!hasContent || isGenerating || !openai) return; + if (!hasContent || isGenerating || isProcessingDocument || !openai) return; // Clear any previous error setError(null); @@ -3221,6 +3242,7 @@ export function UnifiedChat() { [ input, isGenerating, + isProcessingDocument, openai, os, conversation, @@ -3541,8 +3563,22 @@ export function UnifiedChat() { size="sm" className="h-8 w-8 p-0 text-[hsl(var(--maple-secondary-700))] hover:bg-[hsl(var(--maple-primary-container))] hover:text-[hsl(var(--maple-secondary-700))]" disabled={isProcessingDocument} + aria-busy={isProcessingDocument} + aria-label={ + isProcessingDocument ? "Processing document" : "Add attachment" + } > - + {isProcessingDocument ? ( +