Renamed from
watermarks-remover-webin August 2026, at the upstream maintainer's request, so it isn't mistaken for an official component. GitHub redirects the old repository URL; the demo moved to the address below.
Independent, browser-first web client for guillaumemeyer/watermarks-remover, inspired by and compatible with its HTTP API. Not affiliated with the upstream project.
- Runs entirely in the browser for text (Layer A: invisible Unicode / homoglyph spaces) and PNG / JPEG / WebP / AVIF / HEIC / BMP / GIF / TIFF metadata (C2PA, EXIF, XMP, text chunks). No uploads, no analytics, no web fonts, no third-party requests.
- Optionally drives the upstream Python service (
server.py) for everything else: PDF, DOCX, ODT, EPUB, full HTML/SVG/Markdown container cleaning, and pixel-domain backends. - The JavaScript engines are line-for-line ports of upstream's
text_unicode.py,image_meta.py,score_stylometry.pyanddetect_gumbel.py, and a parity test suite asserts identical output (same characters kept/stripped, same bytes out of the image parsers, same stylometry numbers, same keyed-Gumbel p-value). - A Watermark Inspector tab runs every detector on one input and reports each separately across the character, metadata and statistical layers, and can re-run them after a Layer A clean to show what the cleaner did not touch. Keyed-Gumbel (EXP) detection runs in the page itself; the other statistical detectors (Kirchenbauer, SynthID-Text) run through an optional local sidecar.
Live demo: https://ivanusto.github.io/unmark-web/ · Local: open index.html or run python3 serve_local.py.
| Input | Browser engine | Server engine (server.py) |
|---|---|---|
Pasted text / .txt |
Layer A: zero-width & bidi controls, variation selectors, tag chars, PUA, Unicode noncharacters, reserved default-ignorables, other Cf; space homoglyphs; optional NFKC / Latin confusables. Preserves load-bearing invisibles (emoji ZWJ/VS16, Persian/Indic ZWNJ, flag tags, Mongolian FVS, Khmer vowels, Hangul fillers, Arabic Cf, and layout format controls next to their own script: Egyptian quadrat, Duployan, musical beaming) exactly like upstream, with a "paranoid" toggle. |
same |
.md .html .svg |
Layer A on the text only (metadata tags/frontmatter untouched, which the UI flags) | full container cleaning (frontmatter keys, <meta generator>, XMP, …) |
| PNG / JPEG / WebP / AVIF / HEIC | drops tEXt/zTXt/iTXt/eXIf/caBX/c2* chunks, APPn (except JFIF) + COM segments, EXIF/XMP/ICCP/C2PA RIFF chunks with VP8X flag fix-up, and jumb/c2pa/uuid (XMP) ISOBMFF boxes plus their meta sub-boxes. Pixels are untouched (no canvas re-encode). "Keep non-AI metadata" mode only drops blocks with AI/C2PA hints. |
same, plus optional pixel-domain backends if installed |
| BMP / GIF / TIFF | BMP: drops the trailing bytes after the pixel payload (the only place BMP metadata can live) and rewrites the file-size field. GIF: drops comment and XMP/unknown application extensions, keeping NETSCAPE2.0 looping and ICC. TIFF (classic and BigTIFF): walks the IFD chains and drops XMP/EXIF/GPS/IPTC/Photoshop/MakerNote tags, patching each IFD in place so strip and tile offsets stay valid. | same |
| PDF / DOCX / ODT / EPUB | no (needs server) | yes |
| Statistical text watermarks (Kirchenbauer / KGW, SynthID-Text) | detect only, via the optional local sidecar (needs a model and the generator's key); never removed, see upstream Layer B for rewriting | via upstream /detect (MarkLLM harness) when the server advertises text detectors |
| Pixel watermarks (SynthID image, …) | no | via upstream backends only |
Two consequences of the container rules that surprise people:
- A cleaned AVIF or HEIC is the same size as the original. A dropped ISOBMFF box is
overwritten with an equal-size
freebox rather than spliced out, because closing the gap would shift every absolute media offset later in the file and break the image. The metadata really is gone: thefreepayload is zeroed. - A truncated file keeps its truncated tail. If a download was interrupted, the last PNG chunk or ISOBMFF box declares more bytes than the file holds. That tail is copied through verbatim and reported as an action, so a recoverable image is not turned into an unopenable husk that claims it was already clean.
server.py binds to 127.0.0.1:8765, sends no CORS headers, and may require a bearer token, all by design. Three ways to use it from this UI:
serve_local.py(recommended): stdlib, loopback-only static server that proxies/api/*toserver.py, so the browser talks same-origin:# terminal 1: upstream service python3 service/scripts/server.py # from the upstream checkout (or its Docker image) # terminal 2: this UI python3 serve_local.py --upstream http://127.0.0.1:8765 [--api-key "$WATERMARKS_SERVER_API_KEY"] # open http://127.0.0.1:8766/ → the UI auto-selects /api
- Any reverse proxy that serves this directory and forwards a path to
server.py; enter that path (e.g./api) or URL in ⚙️ Server connection. - Direct URL (e.g. from the GitHub Pages build), which only works if something in front of the server allows this page's origin via CORS. Upstream deliberately ships no CORS support in
server.py(the API is meant to be server-to-server; see issue #77 and PR #78), so this means your own reverse proxy in front of it. Do not put a wildcard CORS header on the API.
The API key is only sent as Authorization: Bearer … to the URL you configured, and only stored in localStorage if you tick Remember in this browser.
Cleaning strips the marks; it does not touch the prose. If you also want the text rewritten out of its AI cadence, serve_local.py can proxy an OpenAI-compatible chat endpoint of your own: a local model runner, or anything else you host:
# --llm-upstream base URL, without the /v1 suffix
# --llm-model optional; prefills the model field
# --llm-api-key optional; stays server-side
python3 serve_local.py \
--llm-upstream http://<your-llm-host>:<port> \
--llm-model <model-id> \
--llm-api-key "$YOUR_KEY"
# equivalently: UNMARK_LLM_URL / UNMARK_LLM_MODEL / UNMARK_LLM_API_KEYA Rewrite panel then appears under the cleaned text, with an editable instruction prompt. It sends POST /llm/v1/chat/completions same-origin, so the key never enters the browser and the watermarks service's own bearer token is never attached to it.
Three things worth being explicit about:
- It is off by default. With no
--llm-upstream,/llm/*answers 404 and the panel is not rendered at all. - The hosted build cannot offer it. The demo is HTTPS, and browsers block an HTTPS page from calling a plain-HTTP local endpoint. This is a
serve_local.pyfeature by construction, not an oversight. - Cleaning stays offline; rewriting does not. Text you rewrite is sent to the endpoint you configured. Everything on the Text and Files tabs is still processed in the page unless you connect a server.
The Inspector tab is a detection lab, deliberately separated from the cleaners. It runs every registered detector on the same input and shows one row per detector, grouped by layer:
| Layer | Detectors | Where it runs | What a hit means |
|---|---|---|---|
| Character | invisible / format Unicode, bidi controls, homoglyphs & exotic spaces | browser (js/layer_a.js) |
deterministic: Layer A can strip it, and re-inspecting proves it |
| Metadata | C2PA / Content Credentials, XMP, EXIF / TIFF tags, AI-generator markers, other | browser (js/image_meta.js) |
provenance or generator metadata is present in the container |
| Statistical | keyed-Gumbel / EXP, Kirchenbauer (KGW green-list), SynthID-Text, upstream /detect, TextSeal (placeholder), stylometry (heuristic) |
browser (js/gumbel.js) / local sidecar / upstream server |
the token sequence carries a sampling watermark for the key you tested, nothing more |
Every detector returns the same shape, and Copy JSON report exports exactly that:
{ "detector": "synthid-text", "layer": "statistical",
"status": "detected | clean | uncertain | unavailable | not_tested | not_applicable | error",
"confidence": 0.97, "score": 0.97, "threshold": 0.93,
"evidence": [{ "label": "posterior", "detail": "0.9712" }],
"note": null, "requires_key": true, "requires_model": true, "local": true, "heuristic": false,
"meta": { "model": "Qwen/Qwen3-4B-Instruct-2507", "key_profile": "a", "tokens": 412 } }Three rules the UI enforces, because honesty is the feature:
- Detector and cleaner are separate. Clean (Layer A) & re-inspect runs the cleaner once and shows every detector before / after with a changed? column. When the statistical rows come back identical, the Overall box says so: Layer A cleaning did not affect the statistical watermark detectors.
- A heuristic can never say "detected". Stylometry (burstiness, MATTR, AI-phrase density, ported from upstream's
score_stylometry.py) is capped at uncertain and labelled heuristic on the row. - "Unavailable" is not "clean". Most statistical detectors need the generator's key, tokenizer and a model. On the hosted HTTPS page those report unavailable, and the Overall line says statistical watermarks were not tested there and cannot be ruled out. A clean is likewise scoped to the key and scheme you tested.
The one statistical detector that needs no sidecar, no model weights and no network: a port of upstream's detect_gumbel.py into js/gumbel.js, with its own SHA-256 and HMAC so it runs synchronously and works from file:// as well as from the hosted page. Open Keyed-Gumbel (EXP) key on the Inspector tab and paste the key the text was generated with (a passphrase, or 0x… for raw bytes). The key is held in the page for the length of the run: it is never stored, never sent anywhere, and never written into the JSON report.
The detector replays the keyed sampler's noise from the text alone. For each position it derives seed = HMAC(key, last H tokens) and u = HMAC(seed, token), sums -log(1 - u), and compares that against the Gamma(counted, 1) distribution it would follow if nothing were watermarked. Repeated context windows are skipped, because the generator falls back to ordinary randomness when a window recurs.
Read the result the way upstream intends it:
- It is a same-key replay. It is valid only against the key, tokenizer and PRF layout used at generation, which in practice means a self-hosted engine you control.
- A negative proves nothing. Unwatermarked text, another provider's key and human writing all sit at chance, which is why the row says not this key rather than no watermark.
- The PRF layout is a clean-room instantiation of the scheme (HMAC-SHA256 over packed token ids), auditable but not bit-compatible with any particular engine's kernel. Exact replay against a real engine needs that engine's tokenizer and PRF.
The default context window is 4 tokens and the default verdict threshold is p < 1e-6; both are adjustable next to the key field. The score column shows -log10(p) against -log10(threshold), so bigger is stronger evidence, and the exact p-value is the first evidence row.
sidecar/unmark_stat.py is a small Python service (PyTorch + 🤗 Transformers, GPU recommended) that scores text with the reference Kirchenbauer and SynthID-Text detectors from transformers, using public experiment keys and the independently trained SynthID Bayesian detectors from xlr8harder/synthid (MIT) for Qwen/Qwen3-4B-Instruct-2507. It can also generate a watermarked sample with a chosen key, so you can run the demonstration end to end:
# terminal 1: the sidecar (first run downloads the model and detector bundles)
python -m venv sidecar/.venv && sidecar/.venv/bin/pip install -r sidecar/requirements.txt
sidecar/.venv/bin/python sidecar/unmark_stat.py # 127.0.0.1:8767
# terminal 2: this UI, proxying /stat/* to it
python3 serve_local.py --stat-upstream http://127.0.0.1:8767
Then, in the Inspector: Generate with SynthID-Text and key A → Inspect with key A (detected) → switch to key B (clean / uncertain) → Clean (Layer A) & re-inspect (scores unchanged). Two worlds: the character layer goes to zero, the statistical layer does not move.
Limits, stated plainly: detection is only valid for the same scheme, the same key and the same tokenizer as generation; text from a model whose keys you do not hold cannot be judged, and the sidecar says clean for this key, never not watermarked. Like the rewrite panel, it is a serve_local.py feature, because the hosted page cannot reach a plain-HTTP loopback service.
python3 -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
git clone https://github.com/guillaumemeyer/watermarks-remover ../watermarks-remover # for parity tests
WATERMARKS_UPSTREAM_DIR=../watermarks-remover .venv/bin/pytest -q
node scripts/check-upstream.mjs # upstream hash driftThere is no package.json here, because nothing at runtime or in the test suite needs npm, so the upstream check runs straight through node, exactly as the workflow does. It exits 0 when the recorded hashes still match, 1 on drift, and 2 when it could not check at all (network, rate limit, bad manifest), so an unreachable source is never reported as drift.
js/layer_a.js: port oftext_unicode.py(clean,inspect,decide)js/image_meta.js: port ofimage_meta.py(PNG/JPEG/WebP/AVIF/HEIC/BMP/GIF/TIFF inspect + strip)js/stylometry.js: port ofscore_stylometry.py(burstiness / MATTR / AI-phrase density; heuristic, not a watermark detector)js/gumbel.js: port ofdetect_gumbel.py(keyed-Gumbel / EXP same-key replay, with its own synchronous SHA-256 and HMAC so it needs neithercrypto.subtlenor a secure context)js/detectors.js: the Inspector's detector registry: one result contract for the character, metadata and statistical layers, plussummarize()/compare()for the Overall box and the before/after viewjs/api.js: client for/health /capabilities /inspect /clean /detect, plus the optional/llm-config+/llmrewrite calls and/stat-config+/statsidecar callsjs/i18n.js,js/app.js,css/app.css,index.html: UI (English / 繁體中文 / 简体中文, light/dark, keyboard-accessible). The locale is picked fromnavigator.languagesand remembered inlocalStorage; adding a language is one entry inLANGSplus one dictionary injs/i18n.js.tests/test_layer_a_parity.py,tests/test_image_meta_parity.py,tests/test_stylometry_parity.py,tests/test_gumbel_parity.py: cross-engine parity vs the upstream checkout (skipped ifnodeor the checkout is missing)tests/test_i18n_keys.py: every locale carries every key, and everydata-i18nattribute inindex.htmlresolves. A gap there is invisible at runtime, becauset()falls back silently.serve_local.py: same-origin static +/apiproxy, the optional/llmrewrite proxy and the optional/statsidecar proxysidecar/: the statistical-detector sidecar (its ownrequirements.txt; never part of the page)scripts/check-upstream.mjs: run it withnode scripts/check-upstream.mjs; hashes the upstream Python modules againstscripts/upstream-sources.json;.github/workflows/upstream-check.ymlruns it and the parity suite daily and files an issue when either signal fires. Parity catches behaviour that changed; the hashes catch changes the fixtures do not reach, such as a newly supported format.
No build step, no dependencies at runtime. CSP: default-src 'self'; connect-src * (the latter so you can point at your own server).
Full notes on each release.
- Keyed-Gumbel (EXP) detection in the browser (
js/gumbel.js, port of upstream'sdetect_gumbel.py). The first statistical detector that needs no sidecar, no model weights and no network, so it reaches a verdict on the hosted page. It is a same-key replay:cleanmeans not this key, never no watermark. - Layer A hardening (upstream #133):
U+180F,U+3164andU+FFA0are kept next to their own script and stripped when they float; Unicode noncharacters and reserved default-ignorables are now strip-class; visible-layout format controls (Egyptian quadrat, Duployan, musical beaming) are kept next to their own script. - Image containers (upstream #176, #182, #183): a dropped ISOBMFF box is overwritten with an equal-size
freebox, so a cleaned AVIF or HEIC keeps its length and later media offsets stay valid; a truncated PNG chunk or ISOBMFF box keeps its tail instead of being dropped while the run claims the file was already clean. - Parity anchors moved to
text_unicode.pyab0197b06263,image_meta.py78e5a67db243, and the newdetect_gumbel.pyf908272084cd.
- Watermark Inspector: a third tab that runs every detector on one input and reports each separately across the character, metadata and statistical layers, through one result contract (
js/detectors.js). - Stylometry (
js/stylometry.js, port ofscore_stylometry.py), capped at uncertain because a heuristic is not a watermark detector. - Statistical sidecar (
sidecar/unmark_stat.py): Kirchenbauer/KGW and SynthID-Text detection with the referencetransformersdetectors, proxied same-origin byserve_local.py --stat-upstream. Local only, off by default.
First release. Layer A text cleaning and image container cleaning in the browser, three locales, an optional local AI rewrite behind serve_local.py, and the parity suite that pins both engines to upstream.
MIT, see LICENSE. The character tables, decision rules and container parsers are derived from watermarks-remover, © watermarks-remover contributors, MIT; the upstream notice is preserved in NOTICE. Use on content you own or are authorised to modify; see upstream's ethics notes.