Browser-based checker and cleaner for invisible Unicode characters in text and code.
Paste text or code, see every hidden character exposed in an x-ray view — zero-width spaces, bidirectional controls, tag characters, non-standard whitespace — and copy a clean version. Everything runs locally in the browser: no upload, no sign-up, no logging.
- Detects 64 classes of invisible Unicode characters and labels each one in place
- Cleans text and code contextually, without breaking emoji, Persian, Hindi or other scripts that legitimately need zero-width characters
- Flags bidirectional controls that enable Trojan Source attacks (CVE-2021-42574)
- Makes no network request while you type; keeps working offline
It cannot remove statistical watermarks such as SynthID or token-selection marks — those live in word choice, not in characters, and no character-level tool can touch them.
The detection engine, as a dependency-free ES module. It is the same code that runs in the browser on the hosted version.
src/engine.js the engine: lookup, contextual decisions, cleaning
src/chars.js the character table it runs on (64 entries)
src/index.js public API
bin/wmc.js command-line interface
test/engine.test.js test suite (no framework, no dependencies)
test/fixtures/ expected output captured from the deployed build
examples/demo.js runnable demonstration
docs/API.md full API documentation
import { clean, analyze } from './src/index.js';
clean('hello world'); // U+200B inside the word -> 'hello world'
clean('مر'); // U+200C in Arabic -> unchanged, the script requires it
clean('xy'); // the same U+200C in Latin -> 'xy'Run the tests:
node --test test/engine.test.js
Node 18 or newer. No install step, no dependencies.
Blind removal corrupts real text. A zero-width joiner holds family emoji together and is
required in Devanagari. A zero-width non-joiner is required in Persian. A blank braille
cell is braille content. Tag characters are a hidden payload — except inside a flag emoji,
where they are the flag. Each of those decisions needs the surrounding characters, which
is why decide() in src/engine.js is as long as it is.
Some invisible characters belong to the script they would be tested against: U+3164 is
itself Script=Hangul, U+17B4 is Script=Khmer. Without an explicit exclusion a run of
padding characters would vouch for itself and survive cleaning, which is exactly the trick
they get used for. See SELF_WITNESS in src/engine.js.
The character dataset is published separately as JSON under CC BY 4.0: invisible-unicode-characters
Hosted version: watermarkcheck.com
MIT — see LICENSE.
Maintained by Oleh Vozniak.