diff --git a/CLI.md b/CLI.md index c1ad124..39c6ddb 100644 --- a/CLI.md +++ b/CLI.md @@ -86,6 +86,35 @@ cum inspect --text "C​laude" # ZWSP between C and l curl -s https://example.com | cum inspect --stdin --media html ``` +### `cum enhance` - Stochastic synonym replacement + +``` +cum enhance [OPTIONS] [FILE] +``` + +Applies a best-effort stochastic synonym replacement to plain-text input to defeat Layer B statistical watermarks (SynthID-Text, KGW). By default, each non-stop word is replaced with a 50% probability using a curated synonym table and the system dictionary. + +| Argument | Default | Description | +| ----------------------- | ------- | ------------------------------------------------ | +| `[FILE]` | - | Path to the plain-text file to enhance. | +| `-t`, `--text ` | - | Inline text to enhance. | +| `--stdin` | - | Read plain-text input from stdin. | +| `-o`, `--output ` | stdout | Write the enhanced output to this path. | +| `-p`, `--probability` | `0.5` | Per-word substitution probability `[0.0, 1.0]`. | + +#### Examples + +```bash +# Enhance inline text with a 80% substitution chance +cum enhance --text "The chaos governs the universe" -p 0.8 + +# Enhance a text file and save the result +cum enhance report.txt --output enhanced_report.txt + +# Pipe via stdin +cat essay.md | cum enhance --stdin -p 0.6 +``` + ## Supported Formats | Extension | Detection | Layer | diff --git a/Cargo.lock b/Cargo.lock index 376ffff..aeef563 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "clap" version = "4.6.6" @@ -163,6 +174,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -189,7 +209,9 @@ dependencies = [ "napi", "napi-build", "napi-derive", + "phf 0.14.0", "pyo3", + "rand 0.10.2", "regex", "serde", "serde-wasm-bindgen", @@ -219,6 +241,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "flate2" version = "1.1.9" @@ -328,6 +356,18 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.1", +] + [[package]] name = "hashbrown" version = "0.17.1" @@ -551,7 +591,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -560,8 +611,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -570,8 +621,31 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", - "rand", + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[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.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.119", ] [[package]] @@ -583,6 +657,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-lite" version = "0.2.17" @@ -679,6 +762,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -687,7 +776,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -697,7 +797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -706,9 +806,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1028,7 +1134,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82c3e18d850bb6ebd57735e5654f0af65e572b05c2397e7b2b1a7c6a792cc29c" dependencies = [ - "phf", + "phf 0.11.3", "unicode_names2_generator", ] @@ -1039,7 +1145,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "849744a58c479122ff24910d7ca57f312b62613ef0412dc327776ae6b235d16a" dependencies = [ "phf_codegen", - "rand", + "rand 0.8.7", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b76682f..0226271 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,8 @@ rustdoc-args = ["--cfg", "docsrs"] [dependencies] thiserror = "2.0.20" +rand = "0.10.2" +phf = { version = "0.14.0", features = ["macros"] } unicode-general-category = "1.1.0" unicode_names2 = "3.1.0" unicode-normalization = "0.1.25" diff --git a/NODE.md b/NODE.md index d76eefd..ed47168 100644 --- a/NODE.md +++ b/NODE.md @@ -62,12 +62,3 @@ import { cleanText, inspectText, CleanTextResult, TextInspectReport } from "."; const result: CleanTextResult = cleanText("Hello\u200b!"); ``` - -## πŸ” See Also: Core Logic - -The napi-rs hooks expose standard JavaScript strings and `Uint8Array` primitives directly into the Rust parser. - -For details on the engine architecture and detection heuristics: - -- The rust core leverages zero-copy buffer modifications where possible. -- Text strings are O(1) matching against pre-computed static codepoint slices in `clean_text`. diff --git a/PYTHON.md b/PYTHON.md index 7c64e56..c606337 100644 --- a/PYTHON.md +++ b/PYTHON.md @@ -56,12 +56,3 @@ cleaned = cum_rs.clean_bytes(data) with open("photo.cleaned.png", "wb") as f: f.write(cleaned) ``` - -## πŸ” See Also: Core Logic - -The PyO3 Python hooks seamlessly route queries into the Rust core, ensuring no Python loops block processing. String inspection is zero-copy until modification is detected. - -For architecture and heuristic details on _how_ cleaning works natively, see: - -- `cum_rs::unicode` for text algorithms. -- `cum_rs::image_meta` for PNG/JPEG headers. diff --git a/README.md b/README.md index dac5796..d7f80d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@
-cum-rs logo +cum-rs logo # CUM @@ -18,7 +18,7 @@ > Works regardless of provider (Claude, OpenAI, Gemini, Grok, open-LLM). > All processing is **100% local**: no data leaves your machine. -![crab dancing](assets/crabby-dance.gif) +![crab dancing](https://raw.githubusercontent.com/wiseaidotdev/cum/main/assets/crabby-dance.gif) _The `cum` binary, cheerfully evicting zero-width gremlins from your prose._ @@ -36,7 +36,7 @@ The good news: we have the specialized equipment. And it is written in Rust, so | ------------------ | ------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | | **A: Unicode** | ZWSP, bidi controls, tag chars, variation selectors, private-use codepoints, basically a Unicode horror movie | Deterministic, lossless exorcism 🧹 | | **File: Metadata** | C2PA manifests, EXIF, XMP, document properties, the digital equivalent of a tracking ankle bracelet | Stripped from PNG, JPEG, WebP, SVG, PDF, DOCX, ODT, HTML, Markdown | -| **B: Statistical** | Token-sampling watermarks (SynthID-Text, KGW), watermarks baked into the actual word choices | Best-effort; the only real fix is to rewrite the text yourself, sorry | +| **B: Statistical** | Token-sampling watermarks (SynthID-Text, KGW), watermarks baked into the actual word choices | Best-effort via stochastic synonym replacement: `enhance` command / `enhance_text` API | > **Fun fact:** some of those invisible characters are technically in the Unicode "Tag" block, which was originally designed for plane tickets in 1997 and then deprecated. AI providers found a new use for them. The Unicode Consortium is presumably very proud. @@ -127,6 +127,21 @@ console.log(result.removedCount); // 2 See **[NODE.md](NODE.md)** for the full binding reference. +## 🎲 Stochastic Enhancer + +CUM includes a best-effort countermeasure against Layer B statistical watermarks (SynthID, KGW) by stochastically replacing eligible words with semantically equivalent synonyms. This modifies the raw byte pairs chosen by the LLM's token-sampler, disrupting the periodic watermark signal. + +```rust +use cum_rs::stochastic::StochasticEnhancer; + +let enhancer = StochasticEnhancer::new(0.5); // 50% substitution chance +let output = enhancer.enhance("The chaos governs the universe."); +println!("Substituted {} words", output.words_substituted); +println!("{}", output.text); +``` + +The synonyms table relies on a curated perfect-hash map combined with the host's `/usr/share/dict/` system wordlist. + ## 🚨 Disclaimer _(the responsible adult part)_ **Layer A** (Unicode scrubbing) and **file metadata** stripping are fully deterministic and lossless: every modification is logged in `stats`. You can see exactly what changed. diff --git a/RUST.md b/RUST.md index 9e22954..9f4b819 100644 --- a/RUST.md +++ b/RUST.md @@ -30,6 +30,19 @@ for hit in &report.hits { } ``` +## Stochastic Enhancement (Layer B) + +```rust +use cum_rs::stochastic::StochasticEnhancer; + +// Create an enhancer with 70% substitution probability +let enhancer = StochasticEnhancer::new(0.7); + +let output = enhancer.enhance("The chaos governs the universe."); +println!("Enhanced text: {}", output.text); +println!("Words substituted: {}", output.words_substituted); +``` + ## Image Metadata Stripping ```rust @@ -59,17 +72,3 @@ println!("Chunks removed: {}", output.stats.metadata_chunks_removed); | `python` | PyO3 extension module | | `node` | napi-rs Node.js add-on | | `wasm` | wasm-bindgen WASM bindings | - -## πŸ” See Also: Core Logic & Reasoning - -The core implementation works identically regardless of binding (Rust, Python, Node, WASM). - -1. **`src/unicode.rs`** β€” **Layer A (Text)** - - Text sweeps process string characters asynchronously against a static known list of Unicode categories (`STRIP_CODEPOINTS`, `EMOJI_GLUE`, etc.). - - This operates at `O(1)` per-character space/time complexity via static pattern matching and binary search over codepoint slices. - -2. **`src/image_meta.rs` & `src/container_meta.rs`** β€” **Metadata layer** - - Media cleaners rely on byte boundary scanning or deterministic structural formats (e.g., zip streams for docx). We don't read entire payloads into graphics stacks, we do zero-allocation sub-slice byte patching where possible to ensure robust minimal modifications without risk of arbitrary-code execution on maliciously formed headers. - -3. **`src/cleaner.rs`** β€” **Format Auto-Detection** - - Implements a magic-byte sniffer prioritizing fast chunk detection. If it cannot identify a header (like a PNG `89 50 4E 47`), it falls back to parsing as UTF-8 plaintext. diff --git a/WASM.md b/WASM.md index 08f2950..e94e59d 100644 --- a/WASM.md +++ b/WASM.md @@ -33,9 +33,3 @@ See the live example in [`examples/unmark/`](examples/unmark/): a split-panel Ye ## CORS `cum-rs` operates entirely client-side (pure computation on input bytes). No network requests are made. - -## πŸ” See Also: Core Logic & Architecture - -`cum-rs` leverages WASM to deliver native speeds directly into UI runtimes (Vanilla JS, React, Yew) entirely client-side. No API keys or remote queries are required, offering perfect privacy to end-users. - -All formats (HTML, Markdown, Images, text) are processed via byte-level buffer allocations using standard `Uint8Array` memory shares between JS and WASM linear memory. diff --git a/examples/unmark/Cargo.lock b/examples/unmark/Cargo.lock index 0eb8fce..451040a 100644 --- a/examples/unmark/Cargo.lock +++ b/examples/unmark/Cargo.lock @@ -50,6 +50,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + [[package]] name = "console_error_panic_hook" version = "0.1.7" @@ -60,6 +71,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -74,6 +94,8 @@ name = "cum-rs" version = "0.1.0" dependencies = [ "miniz_oxide 0.9.1", + "phf 0.14.0", + "rand 0.10.2", "regex", "serde", "serde_json", @@ -90,6 +112,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + [[package]] name = "flate2" version = "1.1.9" @@ -204,6 +232,20 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi", + "rand_core 0.10.1", + "wasm-bindgen", +] + [[package]] name = "gloo" version = "0.11.0" @@ -296,7 +338,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "903f432be5ba34427eac5e16048ef65604a82061fe93789f2212afc73d8617d6" dependencies = [ - "getrandom", + "getrandom 0.2.17", "gloo-events 0.2.0", "gloo-utils 0.2.0", "serde", @@ -565,7 +607,18 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" +dependencies = [ + "phf_macros", + "phf_shared 0.14.0", + "serde", ] [[package]] @@ -574,8 +627,8 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "phf_generator", - "phf_shared", + "phf_generator 0.11.3", + "phf_shared 0.11.3", ] [[package]] @@ -584,8 +637,31 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", - "rand", + "phf_shared 0.11.3", + "rand 0.8.7", +] + +[[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.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.119", ] [[package]] @@ -597,6 +673,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.13" @@ -705,6 +790,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.7" @@ -713,7 +804,18 @@ checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" dependencies = [ "libc", "rand_chacha", - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -723,7 +825,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.6.4", ] [[package]] @@ -732,9 +834,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "regex" version = "1.13.1" @@ -1077,7 +1185,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82c3e18d850bb6ebd57735e5654f0af65e572b05c2397e7b2b1a7c6a792cc29c" dependencies = [ - "phf", + "phf 0.11.3", "unicode_names2_generator", ] @@ -1088,7 +1196,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "849744a58c479122ff24910d7ca57f312b62613ef0412dc327776ae6b235d16a" dependencies = [ "phf_codegen", - "rand", + "rand 0.8.7", ] [[package]] @@ -1098,6 +1206,7 @@ dependencies = [ "base64", "console_error_panic_hook", "cum-rs", + "getrandom 0.4.3", "gloo-file 0.4.0", "gloo-timers 0.4.0", "gloo-utils 0.3.0", diff --git a/examples/unmark/Cargo.toml b/examples/unmark/Cargo.toml index ed746c5..2ca5176 100644 --- a/examples/unmark/Cargo.toml +++ b/examples/unmark/Cargo.toml @@ -42,3 +42,4 @@ gloo-utils = "0.3.0" gloo-timers = "0.4.0" base64 = "0.23.1" console_error_panic_hook = "0.1.7" +getrandom = { version = "0.4.3", features = ["wasm_js"] } diff --git a/examples/unmark/src/app.rs b/examples/unmark/src/app.rs index b95e9ef..a139152 100644 --- a/examples/unmark/src/app.rs +++ b/examples/unmark/src/app.rs @@ -7,15 +7,26 @@ //! Top-level application component. +use crate::components::controls_panel::ControlsPanel; use crate::components::header::Header; use crate::components::input_panel::InputPanel; use crate::components::output_panel::OutputPanel; -use crate::types::{CleanResult, CleanStats, MediaKind}; +use crate::types::{CleanResult, CleanStats, MediaKind, StochasticConfig}; use base64::{Engine, engine::general_purpose::STANDARD as B64}; use cum_rs::cleaner::clean; +use cum_rs::stochastic::StochasticEnhancer; use cum_rs::types::MediaHint; +use cum_rs::unicode::{CleanOpts, clean_text}; use yew::prelude::*; +/// Returns the [`StochasticConfig`] default: disabled, probability 50 %. +fn default_stochastic_config() -> StochasticConfig { + StochasticConfig { + enabled: false, + probability_pct: 50, + } +} + #[function_component(App)] pub fn app() -> Html { let text = use_state(String::new); @@ -23,31 +34,59 @@ pub fn app() -> Html { let active_tab = use_state(|| "text".to_string()); let file_bytes = use_state(|| None::>); let file_name = use_state(|| None::); + let stochastic = use_state(default_stochastic_config); let debounce = use_mut_ref(|| Option::::None); + let _recompute_text = { + let result_state = result.clone(); + let stochastic = stochastic.clone(); + move |s: &str| { + if s.trim().is_empty() { + result_state.set(None); + return; + } + result_state.set(Some(run_clean_text(s, &stochastic))); + } + }; + let on_text_change = { let text_state = text.clone(); let result_state = result.clone(); + let stochastic = stochastic.clone(); let debounce = debounce.clone(); Callback::from(move |s: String| { text_state.set(s.clone()); - *debounce.borrow_mut() = None; if s.trim().is_empty() { + result_state.set(None); return; } let rs = result_state.clone(); + let sc = stochastic.clone(); let typed = s.clone(); - let t = gloo_timers::callback::Timeout::new(500, move || { - rs.set(Some(run_clean_text(&typed))); + let t = gloo_timers::callback::Timeout::new(400, move || { + rs.set(Some(run_clean_text(&typed, &sc))); }); *debounce.borrow_mut() = Some(t); }) }; + let on_stochastic_change = { + let stochastic_state = stochastic.clone(); + let text_val = text.clone(); + let result_state = result.clone(); + Callback::from(move |cfg: StochasticConfig| { + stochastic_state.set(cfg.clone()); + let current = (*text_val).clone(); + if !current.trim().is_empty() { + result_state.set(Some(run_clean_text(¤t, &cfg))); + } + }) + }; + let on_tab_change = { let active_tab = active_tab.clone(); Callback::from(move |tab: String| active_tab.set(tab)) @@ -70,15 +109,22 @@ pub fn app() -> Html {
- +
+ + +
@@ -86,24 +132,53 @@ pub fn app() -> Html { } } -fn run_clean_text(text: &str) -> CleanResult { - use cum_rs::unicode::{CleanOpts, clean_text}; +/// Runs the Layer-A clean followed by an optional stochastic enhancement pass. +/// +/// The two passes are chained: enhancement runs on the _already cleaned_ text +/// so the synonym substitution never accidentally re-introduces watermark +/// carriers. +/// +/// # Time Complexity +/// +/// O(n) for cleaning + O(t) for enhancement, where n is the character count +/// and t is the token count. +/// +/// # Space Complexity +/// +/// O(n) for the output buffer. +fn run_clean_text(text: &str, stochastic: &StochasticConfig) -> CleanResult { let opts = CleanOpts { aggressive_confusables: true, ..CleanOpts::safe() }; match clean_text(text, &opts) { - Ok((cleaned, raw)) => CleanResult { - bytes: cleaned.into_bytes(), - stats: CleanStats { - removed_count: raw.removed_count, - replaced_count: raw.replaced_count, - metadata_chunks_removed: raw.metadata_chunks_removed, - summary: raw.summary, - }, - kind: MediaKind::Text, - image_data_url: None, - }, + Ok((cleaned, raw)) => { + let mut final_text = cleaned; + let mut summary = raw.summary; + let mut replaced_count = raw.replaced_count; + + if stochastic.enabled { + let enhancer = StochasticEnhancer::new(stochastic.probability_pct as f64 / 100.0); + let out = enhancer.enhance(&final_text); + final_text = out.text; + if out.words_substituted > 0 { + replaced_count += out.words_substituted; + summary.push(format!("layer_b_syonyms: {}", out.words_substituted)); + } + } + + CleanResult { + bytes: final_text.into_bytes(), + stats: CleanStats { + removed_count: raw.removed_count, + replaced_count, + metadata_chunks_removed: raw.metadata_chunks_removed, + summary, + }, + kind: MediaKind::Text, + image_data_url: None, + } + } Err(e) => CleanResult { bytes: format!("Error: {e}").into_bytes(), stats: CleanStats::default(), @@ -113,6 +188,17 @@ fn run_clean_text(text: &str) -> CleanResult { } } +/// Runs the format-auto-detected clean for binary inputs (images, documents). +/// +/// Stochastic enhancement is not applied to binary inputs; it is text-only. +/// +/// # Time Complexity +/// +/// O(n) where n is the byte length of the input. +/// +/// # Space Complexity +/// +/// O(n). fn run_clean_bytes(bytes: Option<&[u8]>, name: Option<&str>) -> CleanResult { let Some(bytes) = bytes else { return CleanResult { @@ -155,6 +241,7 @@ fn run_clean_bytes(bytes: Option<&[u8]>, name: Option<&str>) -> CleanResult { } } +/// Maps a file-name extension to the appropriate [`MediaHint`]. fn file_hint(name: &str) -> MediaHint { let l = name.to_lowercase(); if l.ends_with(".png") { @@ -180,6 +267,7 @@ fn file_hint(name: &str) -> MediaHint { } } +/// Maps a [`MediaHint`] to its primary MIME type string. fn hint_mime(hint: &MediaHint) -> &'static str { match hint { MediaHint::Png => "image/png", @@ -196,6 +284,7 @@ fn hint_mime(hint: &MediaHint) -> &'static str { } } +/// Classifies a [`MediaHint`] into a [`MediaKind`] for display purposes. fn hint_kind(hint: &MediaHint, name: &str) -> MediaKind { match hint { MediaHint::Png => MediaKind::Image("image/png".into()), diff --git a/examples/unmark/src/components.rs b/examples/unmark/src/components.rs index 492c1ff..88aea8f 100644 --- a/examples/unmark/src/components.rs +++ b/examples/unmark/src/components.rs @@ -5,6 +5,7 @@ // option. This file may not be copied, modified, or distributed // except according to those terms. +pub mod controls_panel; pub mod header; pub mod input_panel; pub mod output_panel; diff --git a/examples/unmark/src/components/controls_panel.rs b/examples/unmark/src/components/controls_panel.rs new file mode 100644 index 0000000..b7ef3ac --- /dev/null +++ b/examples/unmark/src/components/controls_panel.rs @@ -0,0 +1,147 @@ +// Copyright 2026 Mahmoud Harmouch. +// +// Licensed under the MIT license +// , at your +// option. This file may not be copied, modified, or distributed +// except according to those terms. + +//! # Controls Panel +//! +//! A sidebar panel that exposes the stochastic synonym-replacement settings. +//! +//! ## Controls +//! +//! | Control | ID | Description | +//! |---------|----|-------------| +//! | Toggle | `stochastic-toggle` | Enable / disable synonym replacement. | +//! | Slider | `prob-slider` | Per-word substitution probability (1 – 100 %). | +//! +//! Every change fires the parent callback immediately (no debounce) so the +//! output panel reflects the new setting on each interaction. + +use crate::types::StochasticConfig; +use wasm_bindgen::JsCast; +use web_sys::{HtmlInputElement, MouseEvent}; +use yew::prelude::*; + +/// Props for [`ControlsPanel`]. +#[derive(Properties, PartialEq)] +pub struct ControlsPanelProps { + /// Current stochastic configuration. + pub config: StochasticConfig, + /// Fired immediately whenever any control value changes. + pub on_change: Callback, +} + +#[function_component(ControlsPanel)] +pub fn controls_panel(props: &ControlsPanelProps) -> Html { + let on_toggle = { + let config = props.config.clone(); + let cb = props.on_change.clone(); + Callback::from(move |_: MouseEvent| { + cb.emit(StochasticConfig { + enabled: !config.enabled, + probability_pct: config.probability_pct, + }); + }) + }; + + let on_prob_input = { + let config = props.config.clone(); + let cb = props.on_change.clone(); + Callback::from(move |e: InputEvent| { + if let Some(el) = e + .target() + .and_then(|t| t.dyn_into::().ok()) + { + if let Ok(v) = el.value().parse::() { + cb.emit(StochasticConfig { + enabled: config.enabled, + probability_pct: v.clamp(1, 100), + }); + } + } + }) + }; + + let track_class = if props.config.enabled { + "um-toggle-track bg-um-accent" + } else { + "um-toggle-track bg-um-border" + }; + + let thumb_class = if props.config.enabled { + "um-toggle-thumb translate-x-4" + } else { + "um-toggle-thumb translate-x-0" + }; + + html! { +