Repository navigation
Releases: ivanusto/unmark-web
Release list
v0.9.1: padding after the last box is not a truncation
- A few bytes of padding after the last box no longer bring the byte scan back. v0.9.0 followed #371 in scanning a container's raw bytes for C2PA markers only when its box walk stopped early, but the test was "stopped before the end of the file". The walk needs 8 bytes to read a box header, so a complete MP4, AVIF or HEIC with 1 to 7 bytes of trailing padding passed it, and a
c2pathat happened to sit in the media was reported as a manifest. guillaumemeyer/watermarks-remover#382 asks for at least 8 bytes left over, the same threshold the cleaners already use to tell a truncated file. Both drivers follow it. A real truncation, including a content-provenance box cut off before any ASCII marker, is still caught. - 1778 tests, twelve upstream anchors, one of them moved to upstream
c5297e9.
Ports upstream guillaumemeyer/watermarks-remover at c5297e9. Closes #31.
v0.9.0: comments that name the author go, apostrophes stop meaning provenance
- HTML and Markdown lose the comments that say who wrote them. A
<!-- generated by ChatGPT -->survived cleaning in both formats; only SVG handled provenance comments. guillaumemeyer/watermarks-remover#366 reports and drops a<!-- -->comment that names an AI tool or says AI-generated, C2PA or content credential, so a static site generator's own comment and a<!-- toc -->marker stay. In Markdown, comments inside a fenced code block are left alone, with CommonMark's rules for where a fence opens and closes: a line such as``` not a closedoes not end one, and a backtick fence whose info string holds a backtick is not a fence. The fence scanner is written out by hand rather than with a multiline regex, because JavaScript's^and$also hold at a lone carriage return and at U+2028, where Python's hold only at a line feed. - An apostrophe no longer turns a page description into provenance.
<meta name="description" content="Bob's favorite Claude Code skills">was reported as AI metadata and deleted: the attribute patterns stopped a value at either quote character, so the value was never blanked and "Claude" was matched as a name. #373 reads a value up to its real closing quote. It cuts the other way too:content="Bob's C2PA manifest"used to parse asBoband now flags. - Marker bytes inside a complete video or image are no longer a C2PA finding. Coded media contains short ASCII strings like
jumborc2paby chance, and inspection fell back to scanning the whole file for them even when every box had parsed. #371 keeps that scan only for a box walk that stopped early, a truncated download, which is what it was for. Both drivers follow it, and the slice driver gains the most: inspecting a complete 128 MiB MP4 used to read the file twice over (256 MiB, 395 ms in Node); it now reads 176 bytes of box headers and takes under a millisecond. - The U+0130 divergence is over. Upstream fixed #354 in #364 the same way this port always worked, lowercasing ASCII only. The two tests that asserted the divergence in both directions now assert agreement on the inputs that used to separate them.
js/pyre.jstranslates.and\dthe way CPython reads them. Python's.refuses only a line feed, where JavaScript's also refuses a carriage return, U+2028 and U+2029; Python's\dmatches any Unicode decimal digit, where JavaScript's is ASCII. The new patterns were the first to lean on either, and four of the new fixtures fail, in inspect and in clean, without the translation.- 1691 tests, twelve upstream anchors, five of them moved to upstream
258cf20.
Ports upstream guillaumemeyer/watermarks-remover at 258cf20. Closes #29.
v0.8.0: the same answers, with most of the copying taken out
Six optimisations, none of which alters a byte of output. Every one sits on a path the parity suite compares against upstream Python, and that suite is what says so: 1532 tests, twelve upstream anchors unchanged.
Figures are from the harness kept with the results, on one machine, medians of repeated warmed runs.
The document is no longer lowercased in full, twice, to find two substrings
The scanners for <script and data:image/ built a copy of the whole file with its ASCII letters lowered and searched the copy, because that is what upstream's text.lower().find(...) does. Searching the original case-insensitively finds the same offsets and needs no copy. The i flag without u folds ASCII and nothing else, which is exactly the ASCII-only rule this port already chose over upstream's length-changing str.lower(), so the U+0130 divergence and the tests asserting it in both directions are untouched.
UTF-8 in and out takes the platform codec when it can
TextDecoder in fatal mode succeeds only on well-formed input, and well-formed input never reaches either of CPython's error handlers, so both of the port's decode modes agree with it. Anything invalid throws and falls through to the hand-written scanner, which is where CPython's exact reporting of a broken sequence lives. encodeUtf8 takes TextEncoder unless the string carries a lone surrogate, which is the only thing the two disagree about.
ignoreBOM: true is the trap worth naming: without it TextDecoder swallows a leading U+FEFF and bytes.decode() does not. There are now fixtures for valid UTF-8, because the existing fuzz is random bytes, which are almost never valid and so never reached the fast path at all.
Base64 and percent-encoding stopped building their output one byte at a time: b64decode and unquoteToBytes write into a buffer sized once, b64encode and quoteFromBytes read from precomputed tables.
The Inspector no longer reads a file on the thread that draws the page
It read every image whole and then had the bytes copied again into the worker, and neither copy was any use to the page: only the engine wants them. The File crosses instead and is read where it is used, so a 24 MiB image is allocated once rather than twice. The saving on the inspect as a whole is modest, because the scan itself dominates; the transport is where it shows.
The first screen stops downloading the cleaning engines
They are most of this page's JavaScript, the worker loads its own copy through importScripts, and the first screen uses none of them. They were in the script tags only for file://, where there is no worker to load them, so js/engine.js now injects them itself in that one case. Verified in a headless browser against the real page on both paths.
One real defect found on the way
js/pyre.js widened a literal i to also match ı and İ whether or not the pattern asked for IGNORECASE. CPython does that folding only under IGNORECASE. No shipping pattern happened to be case-sensitive and contain an i, which is exactly why no parity suite noticed: a suite can only see the rules its ported patterns exercise. tests/test_pyre_semantics.py now pins the translation against CPython's re directly.
v0.7.1: cleaning an agent definition stops deleting its configuration
A Claude Code agent or skill definition is configuration, not provenance, and cleaning one used to remove its tool grant and its model. Syncs guillaumemeyer/watermarks-remover#345.
Cleaning an agent definition stops deleting its configuration
Markdown cleaning drops the whole key when a value looks like provenance. A .claude/agents/*.md or SKILL.md carries
name: inbox-router
description: "Triages the inbox and routes each mail to its project."
tools: Read, Write, Edit, mcp__claude_ai_acme__mail__get-message
model: opusand model is one of the AI frontmatter keys, so cleaning such a file removed the model. Before upstream #336 the bare claude inside the MCP tool name removed the tool grant as well. The file still parsed afterwards, so nothing looked broken: the agent had simply lost the configuration the file exists to carry.
Frontmatter carrying name and description and either tools or allowed-tools is now recognised as an agent or skill definition by that shape, because there is no path to look at, and those keys are treated as configuration. Values are still scanned inside one, so description: Generated with Claude Code is still caught and still stripped, and two of the three keys is ordinary frontmatter that gets no exemption.
A key that names a producing tool is recognised in more spellings
generated-with:, generated_with:, made-with:, written-by:, produced_by: and authored-by: now count as generator naming keys, so a bare vendor name in their value is provenance rather than subject matter.
In free prose the phrasing generated with, created by, written using and the rest of that family is a marker on its own. A bare vendor name still is not, which is what keeps "Claude Monet painted this" a description.
Notes
Upstream's PR body describes a new AI_META_VALUE_RE that the merged commit does not contain: #336 and #342 had already built the key/value asymmetry into named_value_is_ai. This port follows the merged source, not the description.
New parity anchors: container_meta.py #ai-vocabulary 10bfb05e2945, #markdown 868ad8818b39. 1446 tests, all twelve anchors green.
v0.7.0: the container formats stop needing a server
SVG, HTML and Markdown are cleaned in the browser now, the engines run off the thread that draws the page, and one upstream defect is deliberately not copied.
The container formats stop needing a server
These three have been in the accept list since the first release, but only as text: Layer A ran over the body and a findings line said the metadata inside needed the Python service. The text-markup half of upstream's container_meta.py is pure regex and string work with no zipfile, no PDF, no subprocess and no network, so it runs in a page unchanged.
- SVG:
<metadata>and<x:xmpmeta>blocks, XML DOCTYPE and ENTITY declarations (#288), comments carrying AI markers, generator-like root attributes. - HTML:
<meta>tags that name a generator, JSON-LD provenance blocks,data-ai*attributes. A plain CMS generator tag stays, which is the rule upstream #336 and #342 settled: a value names a generator only under a naming key, and everything else is free prose, so a description about "a static site generator" survives. - Markdown: AI frontmatter keys, with the nested lines under them. Dropping
model:and leavingname: claude-opusbehind is the failure this avoids. - All three: embedded
data:image/…payloads run back through the PNG, JPEG, WebP and ISOBMFF strippers this port already had, then Layer A over the body, which is the order upstream'sclean_containeruses.
A file that is not valid UTF-8 comes back byte for byte
Upstream decodes these formats with surrogateescape and re-encodes at the end. The page used to read them through File.text(), which substitutes replacement characters, so such a file came back rewritten rather than cleaned. Both of upstream's decode modes are written out by hand in js/container_meta.js, because TextDecoder offers neither, and so are CPython's base64.b64decode, quote_from_bytes and unquote_to_bytes, whose rules differ from atob and encodeURIComponent in ways that change bytes. All of them are tested against CPython directly, over random bytes as well as named cases.
One upstream defect this port does not copy
_iter_script_blocks and _iter_data_uris lowercase the whole document to locate <script and data:image/, then index the original string with the result's offsets. That holds only while lowercasing preserves length, and U+0130 (LATIN CAPITAL LETTER I WITH DOT ABOVE) lowercases to two characters, in Python and in JavaScript alike.
With one such character earlier in the file, upstream's inspect_html reports nothing and its clean_html changes nothing for a document whose JSON-LD says trainedAlgorithmicMedia, and an embedded PNG parses as MIME type ng with a truncated payload. A single character of Turkish or Azerbaijani text is enough.
This port lowercases ASCII only, which cannot change length. Reported as guillaumemeyer/watermarks-remover#354. The divergence is asserted in both directions, so it fails loudly if upstream fixes this and the two converge, and it is recorded in scripts/upstream-sources.json rather than left in a commit message. This is the second such divergence, after the truncated-MP4 one upstream later took as #242.
The engines run in a Web Worker
A 64 MiB image is scanned and rebuilt in one synchronous pass, keyed-Gumbel does four pure-JS SHA-256 compressions per token, and the stylometry marker table walks the whole text once per pattern: 383 ms for a megabyte of ASCII, 1692 ms for the same text carrying zero-width characters, which is V8 taking the two-byte string path rather than anything this code does. For however long those took, nothing repainted and no button answered.
js/engine_ops.js holds the work as plain functions with no DOM in it, and both sides load it, so the worker path and the same-thread path run the same code rather than two copies that can drift. A File crosses by reference and the cleaned bytes come back transferred, so nothing is copied either way.
A page opened straight off the filesystem cannot start a worker, and the README says to open index.html that way, so the same op table runs in place there. Both paths are verified in headless Chromium against the real page, not against the modules.
Smaller things
- The Inspector no longer refuses a recording the clean tab accepts. It read every input whole and applied the 64 MiB cap to audio and video too, while the clean tab has exempted them since v0.4.0 because of the slice driver. The detector input contract now carries either the bytes or the
File, and the five metadata detectors share one pass through the headers. - A stale upstream checkout used to abort the whole test run.
tests/test_contains_any_parity.pyread a marker list at import time, outside its ownskipif, so anAttributeErrorthere ended collection for every file: 965 collected, 1 error. The lists are read throughgetattrnow, and a set built from a missing list is reported as a skip rather than dropped in silence. - The dropzone said EPUB needed a server in one line and offered it in the next. Both READMEs left
av_meta.pyout of the line-for-line port list it has been in since v0.4.0. Two dictionary keys were dead in all three locales. - The Python
reandstremulation moved out ofjs/stylometry.jsintojs/pyre.js, unchanged, because the container port needs the same rules; the stylometry parity suite is what proves it is the same code. - The nine script tags moved into the head with
defer.
Parity anchors
container_meta.py is tracked as six slices rather than as a file: it is 4,000 lines and most of it is ZIP and PDF work with no counterpart here, so whole-file hashing would open a drift issue every time any of that moved. The check now covers twelve sources.
1403 tests against upstream fd44c13, no skips, up from 1035. 259 of the new ones are the SVG and data-URI port, whose fixtures are upstream's own hardening suite, all thirteen cases, compared byte for byte rather than asserted about.
v0.6.2: two upstream syncs, and a Chinese README that caught up
Two upstream syncs, both of them a case of "this file was never read, and was reported clean anyway", plus a translation that had fallen three releases behind.
A PNG text chunk can no longer inflate without a bound
Ported from upstream #308. A decompressed zTXt/iTXt value is capped at 1 MiB. PNG text is metadata, not a document: a few hundred KB of crafted deflate expands to hundreds of megabytes, and the marker scan then concatenates the payload with the decoded text and copies it again.
The inflate here already had a 16 MiB ceiling, but hitting it was treated the way corrupt input is treated: return nothing, say nothing. That is the wrong answer to this chunk is too big to read. Nothing was scanned, so nothing can be ruled out.
inspectPngreports the chunk as not fully inspected instead of leaving it out of the findings.stripPngdrops it even in "keep non-AI metadata" mode: a chunk nobody could read is not a chunk anyone can vouch for.- In strip-all mode the
orshort-circuits before the inflate, exactly as upstream's does, so the action stays the plain one.
#308 changed one more thing in passing. decompressobj hands back what it decoded before the input ran out, where zlib.decompress raised on a truncated stream and the whole chunk went unscanned. So the inflate now separates the two failures it used to collapse: input that simply ends returns the partial text, input that is malformed still returns nothing. A generator name in the surviving prefix of an interrupted download is found now, and was not before.
A truncated ID3v2 tag is reported, and the audio survives it
Ported from upstream #201. An MP3 whose ID3v2 header declares more tag than the file holds used to fall straight through: the frame parser refused it, inspect reported no findings, clean reported no actions, and a file that was never read past its header came out labelled clean.
- Inspect checks the declared length against the bytes that are there before parsing anything, reports the tag as truncated, and scans what did arrive for markers. A partial tag is reported as partial, not as absent.
- Clean cannot trust the length either, so it drops everything up to the first valid MPEG frame header: sync word plus version, layer, bitrate, sample rate and emphasis, because two bytes that look like a sync word are not an audio frame. A file with no such header anywhere is preserved exactly as it was, rather than emptied in the name of cleaning it.
- Keep mode changes none of this, upstream included: a tag that cannot be read cannot be filtered frame by frame.
- Both drivers carry it, including the report on a WAV
id3chunk holding a truncated tag. The slice driver walks for the frame header in overlapping chunks rather than reading the file into memory.
The Traditional Chinese README caught up
README.zh-TW.md had its changelog frozen at v0.4.2, and parts of its body had drifted with it: two rows of the format table still described the uuid box as XMP's alone, and the development section was missing four parity files, the -rs flag and the slice feature. v0.5.0, v0.6.0, v0.6.1 and this release are in. Both module lists were also missing js/av_meta.js, which has been there since v0.4.0.
Not ported, on purpose
Recorded in scripts/upstream-sources.json rather than mirrored, because a page has no counterpart: #302's strength to intensity rename on the CtrlRegen and DiffusionPurification subprocess flags, #201's synthid_is_watermarked and SynthID verdict finding in inspect_image (needs a pixel scorer this port does not have), and the data parameter threaded into run_synthid_score and inspect_av to skip a read-back that never happens here.
Parity anchors
image_meta.py bd5d9f19f370 · av_meta.py f012ed17276c · text_unicode.py, score_stylometry.py, detect_gumbel.py and the common.py slice unchanged.
1042 tests against upstream 946e4fc, no skips. Twelve new parity fixtures and four behaviour tests; without the ports, 22 of the new cases fail.
v0.6.1: the explanation moves out of the way
A correction to v0.6.0.
The explanation moved below the tool. v0.6.0 put the About section above the tabs, which pushed the textarea off the first screen: someone who came here to clean a file had to scroll past a description of the file to reach it. It now sits under the tab panels as an "About Unmark" section, separated by a rule and introduced by a heading, with the same three cards and the same weight on the caveat.
The card icons are gone. The ochre rule along the top of the caveat card carries the emphasis on its own. The rest of the page's emoji (tab labels, brand tile, privacy banner) predate that section and are untouched.
No engine changes. 971 tests, no skips. Parity anchors unchanged.
v0.6.0: a page that explains itself, in Morandi
No engine changes. This is the page saying what it is for, and looking calmer while it does.
The page explains itself now
A visitor landing on the demo got a one-line tagline and a row of tabs. There is now a lead paragraph and three cards above the fold: what it removes, what it cannot remove, and how to trust it.
The middle card carries the same weight as the other two rather than being a footnote. Statistical watermarks (SynthID-Text, KGW, keyed-Gumbel) live in how tokens were sampled, and pixel-domain watermarks live in the image itself. Neither is a character or a metadata field, so stripping metadata does not remove them. That difference is the whole reason the Inspector tab exists, and a page that only advertised removal would be making the one claim this project has spent its life refusing to make.
Title, meta description and tagline lead with watermark removal instead of listing file formats. Three locales, and both READMEs open the same way.
Morandi palette, light and dark
Low-saturation, grey-leaning hues on a warm neutral ground, every colour carrying some of the same grey. Eucalyptus accent, sage / ochre / faded terracotta for status, unbleached linen or warm charcoal for the page.
Both themes were already wired up, so this is a token swap, plus the thirteen colours that had been written out by hand in rules scattered through the stylesheet. The palette lives in one place now.
The accessibility claim is a test
Muting is one step away from unreadable. tests/test_theme_contrast.py parses the tokens straight out of css/app.css and checks that every text and badge colour clears WCAG AA (4.5:1) against every background it can sit on, in both themes, compositing the translucent card colour over the page first. It also fails if the light theme forgets to override a colour token, which would leave a dark-theme value on a pale ground with nothing to notice it.
--text-muted is the one documented exemption: 10-12px flags and hints that repeat what an adjacent AA colour already says.
The tagline is hidden below 560px, where it wrapped to five lines and pushed the header controls off the row.
971 tests, no skips. Parity anchors unchanged from v0.5.0.
v0.5.0: C2PA by user type, and a scorer that admits when it cannot score
Two upstream syncs, plus the drift check learning to track a single definition.
C2PA manifests in AVIF, HEIC, MP4 and MOV are recognized by user type
C2PA stores its manifest in a BMFF container as a top-level uuid box whose user type is d8fec3d6-1b0e-483c-9297-5828877ec481, not as a c2pa box. This port special-cased XMP's user type and otherwise looked for ASCII c2pa/jumb in the payload, so a manifest carrying neither (an auxiliary merkle box, a binary manifest store) was caught by luck in strip-all mode and missed entirely in "keep non-AI metadata" mode.
Ported from upstream #264. The user type is accepted at payload offset 0 or 4 (the defensive FullBox layout), at top level and inside meta, and the whole-file fallback requires it to follow a uuid fourcc so a coincidental byte sequence in mdat is not reported. The equal-size free replacement is unchanged: a cleaned file keeps its length and mdat does not move.
Stylometry no longer scores a sample it cannot measure
Ported from upstream #258.
- Below the 30-word calibration floor, score and confidence level are absent rather than
0.0/CLEAN. Calling a 20-word sample CLEAN was a claim with nothing behind it. The Inspector says the sample was too short to score instead of showing a dash. - Every report carries a
density_tier(uncalibrated | low | medium | high), a different ladder from the confidence level. - Twenty new phrase patterns, and match
spanson every marker. Spans are the one place a faithful port is not a transcription: Python counts code points and a JS string index counts UTF-16 code units, so one emoji before a match would shift every span after it.
Upstream #273 is in too: a Layer A space homoglyph is informational, not probable.
The drift check can track one definition, not just whole files
common.py is not ported wholesale, only classify_finding_confidence, which StylometryReport.to_dict() calls. Hashing the whole file would have filed an issue every time upstream touched anything else in it, and an issue that is usually noise is an issue nobody reads. So it was tracked by nothing, and #273 went unnoticed.
A source in scripts/upstream-sources.json can now carry a slice, and common.py is tracked that way. The drift table marks such a row (one definition only).
Also
CI ran pytest -q from the repo root, where the workflow also checks out upstream/, so this repo's verdict included upstream's own test suite. Both workflows now run pytest tests/ -q -rs; the -rs because a parity file that skips itself when it cannot find its upstream module is otherwise a silent pass.
Parity anchors
image_meta.py 360da6bac49f · score_stylometry.py 57dcbd2cb1ec · common.py tracked by slice · text_unicode.py, av_meta.py and detect_gumbel.py unchanged.
932 tests against upstream 3f84d3e, no skips.
v0.4.2: the UI admits it handles audio and video
A copy release. The engines gained audio and video in v0.4.0, but the page never stopped calling itself an image tool.
What the UI was still saying
MP4/MOV/M4A/M4V, MP3, WAV and FLAC have been cleaned and inspected since v0.4.0, and both file pickers have accepted them the whole time. The words around them did not move:
- the tagline and the Files tab label named images only
- both dropzone descriptions listed every image format and no media one, and the inline English fallback in
index.htmlwas older still, predating BMP/GIF/TIFF - the Inspector's button, empty state and clean-and-re-inspect note said "image file", though
js/detectors.jshas routed file input toAvMeta.inspectAvsince the port landed - the not-applicable note told you a file was not an image format
- the
<meta name="description">listed C2PA/EXIF/XMP, none of which covers ID3
Nine strings, all three locales, plus the two stale inline fallbacks the dictionaries shadow at runtime.
One correction that predates audio and video
The server engine description promised that images were still handled on this device. They are not: when a server is connected, every file dropped into the Files tab is sent to it, images included. Only text pasted into the Text tab stays local, and the description now says that.
No engine, parity or format changes. Test suite: 837 passed.
