Text you paste out of a chat window, a PDF or Word is almost never plain text. It carries typographic punctuation, spaces that are not spaces, and occasionally something genuinely invisible. This page explains what is in there, which of it matters, and what the honest answer is to the question most people arrive with: does any of it prove the text came from an AI.
What AI output really contains
Run a paragraph from ChatGPT, Claude or Gemini through the detector and you will usually see the same short list.
| Character | Where it comes from | What it breaks |
|---|---|---|
| Curly quotes | Training data, plus the chat interface rendering HTML | Code, JSON, shell commands, exact search |
| Em dash and en dash | Same | CSV columns, slugs, filenames |
U+00A0 non-breaking space | Inserted around units, numbers and before punctuation | Splitting on spaces, number parsing, trimming |
U+202F narrow no-break space | Several models emit it before a percent sign and in figures | The same, and it is even harder to spot |
U+00AD soft hyphen | Mostly from PDFs, occasionally from generated text | Word matching, search, spell check |
U+200B zero-width space | Copied through a web page or an editor | String comparison, uniqueness checks |
None of this is a marker anyone put there deliberately. Part of it is the model reproducing the punctuation of the published prose it learned from, part of it is the interface: copying from a rendered chat bubble means copying HTML, and HTML gives you typographic characters. The same happens when you copy from a news site or a Google Doc.
What it does do is break the next step. A curly apostrophe in a code block is a syntax error. A non-breaking space in a spreadsheet column means the number stays text. A soft hyphen in a product name means the search for it returns nothing. That is the reason to check and clean, and it applies whether a person or a model wrote the sentence.
How to read the report
Paste on the left, read on the right. Cleaning is on by default, so the copy and download buttons already hand you the fixed text.
- Findings. One entry per distinct character, sorted by severity, each naming the character, counting it, explaining what it does and listing the first positions as line and column.
- Annotated text. Your text with every flagged character replaced by a chip showing its codepoint. This is the view that answers where exactly the thing sits.
- Cleaned text. The version to paste onward.
The four numbers underneath: characters is the real codepoint count, which differs from what String.length reports in JavaScript for anything above the basic plane. UTF-8 bytes is what a database column with a byte limit counts. Invisible counts only what renders as nothing, and flagged counts everything the report picked up, visible lookalikes included.
--all-non-ascii
Highlights ordinary non-ASCII characters as well. Use it when something has to be pure ASCII, a slug, a filename, a database identifier, and you want to see every character that is not.
The character classes
| Class | Examples | What breaks |
|---|---|---|
| Zero-width | U+200B, U+200C, U+2060, U+FEFF | String comparison, uniqueness checks, search. Nothing renders, so the bug is invisible in every screenshot. |
| Bidi controls | U+202A to U+202E, U+2066 to U+2069 | Display order. Source code that reads one way and compiles another. |
| Tag characters | U+E0000 to U+E007F | Invisible copies of ASCII. Carry hidden instructions into anything that does not filter them. |
| Exotic spaces | U+00A0, U+2009, U+202F, U+3000 | Splitting, trimming, number parsing. A spreadsheet column that will not become a number. |
| Line separators | U+2028, U+2029 | JavaScript string literals before ES2019, JSONP, older bundlers. |
| Control bytes | U+0000 to U+001F, U+007F to U+009F | Terminals interpret them, C-based parsers truncate at NUL, log viewers hide them. |
| Typographic | U+2018, U+201D, U+2014, U+2026 | Anything that expects ASCII punctuation: compilers, CSV, URLs, exact match. |
| Lookalikes | Cyrillic а е о р с, Greek ο ν, fullwidth forms | Domains, package names, usernames. Renders identically, compares as different. |
| Replacement | U+FFFD | The tell that bytes were already decoded with the wrong encoding. The original characters are gone. |
Two are treated gently on purpose. A zero-width joiner between two emoji is doing its job, so it is reported as information and survives cleaning. Variation selectors are the same case: they decide whether a symbol renders as text or as an emoji, and stripping them changes what people see.
This is not an AI detector, and neither is anything else that counts characters
The question comes up constantly, so here is the straight answer. Finding an em dash, a curly quote or a zero-width space tells you the text passed through something that produces typographic output. That set includes every chat interface, but also Word, Google Docs, every CMS editor, most email clients and any copy out of a browser. A paragraph a person typed in Word and a paragraph a model generated look the same under a codepoint inspection.
Nor is there a hidden marker to find. The published research on text watermarking works by nudging which tokens a model selects, so the signal lives in word choice and is recoverable only with the key that produced it. It inserts no characters and would not show up in a report like this one. No major vendor has shipped a character-level watermark, and if one ever did, stripping it would be one regular expression away, which is precisely why nobody builds it that way.
Use the report for what it is good at, which is finding the characters that are about to break your import, your build or your search index. It is not evidence of authorship, and we would not present it as such.
Tag characters and bidi overrides
Two classes are worth more than a shrug when they show up.
Tag characters are the block from U+E0000 to U+E007F, an invisible one-to-one mirror of ASCII. They render as nothing, survive copy and paste, and arrive intact at anything that reads the raw string, which today includes language models. A block of them appended to an ordinary sentence is a working prompt injection: the human sees a support request, the model reads an instruction. The report collapses a whole block into one finding and decodes it back to ASCII, so you get the hidden sentence rather than a list of eighty codepoints. Nothing inserts these by accident, so treat a finding as deliberate, and filter the range at the input boundary of anything that feeds a model.
Bidirectional overrides tell the renderer to display a run of text right to left. In Arabic or Hebrew that is correct behaviour. In a source file it means the reviewer and the compiler see different programs, which is the Trojan Source attack from 2021. If the report flags U+202E in something you are about to merge, stop and read the file with the characters made visible.
Lookalikes and mixed scripts
Unicode contains dozens of characters that render like a Latin letter in most fonts. Cyrillic has a whole set of them because the alphabets share ancestry: а е о р с у х are visually identical to a e o p c y x and completely different codepoints. Greek adds ο ν α, and the fullwidth forms from U+FF01 give you a second ASCII. The full catalogue is Unicode's own confusables data in UTS #39, which is what browsers and registries check domain names against.
The detector only flags these inside a word that genuinely mixes alphabets. A Cyrillic о in a Cyrillic word is a letter; the same character inside administrator is a spoofing attempt. Most tools skip that test and flag every Cyrillic character they see, which makes them useless on anything but English. Where it matters: lookalike domains, package names that pass a review by eye, a second account called admin, and copied shell commands that fail with "command not found" for no visible reason.
The rule has one blind spot worth knowing. A word written entirely in lookalikes, with no Latin left to mix with, does not trigger it. Switch on --all-non-ascii when you are checking something that must be ASCII and want no doubt at all.
What --clean actually does
Cleaning is not "delete everything unusual". Each class is handled the way you would want it handled:
| Input | Becomes |
|---|---|
| Zero-width, bidi, tag, control characters | removed |
| Every non-standard space | U+0020 |
U+2028, U+2029 | a normal line break |
| Curly quotes, en and em dashes, ellipsis, prime | the ASCII equivalent |
| Lookalikes inside a mixed-script word, fullwidth forms | the Latin letter they imitate |
| Zero-width joiners inside emoji, variation selectors | kept |
U+FFFD | kept, because it marks data that is already lost |
| Everything else | kept, then normalised to NFC |
The replacement character is deliberately left in. Removing it would hide the fact that the text went through a broken decode, and the fix for that is upstream, at the point where the bytes were read with the wrong encoding. If the report shows U+FFFD, do not clean the text, go and find the original bytes.
Finding them in a codebase
One string at a time is fine for debugging. For a repository, use tools that scan:
grep -rPn "[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{FEFF}]" .finds the zero-width and bidi set. GNU grep with PCRE, so on macOS install it or usergwith the same pattern.- VS Code has highlighted invisible and ambiguous characters since 1.63 through
editor.unicodeHighlight. It is on by default and worth leaving on. - ESLint has
no-irregular-whitespace, which covers the space classes in JavaScript source but not identifiers or string content. - A pre-commit hook running the same grep costs nothing and catches the pasted snippet before it reaches a review.
For data rather than code, fix it at the boundary. Normalise to NFC and strip the format and control categories when input arrives, not when a comparison fails three services later. The guide on invisible Unicode characters goes through the attack history in more detail, and if your problem is mangled accented letters rather than invisible ones, the mojibake explainer is the one you want.
Hunting invisible characters
Does ChatGPT add invisible characters to its output?
Not as a hidden marker, but its output regularly contains characters that are not plain ASCII. The common ones are curly quotes, en and em dashes, non-breaking spaces around units and numbers, and a narrow no-break space at U+202F that several models emit before a percent sign. Some of it comes from the model, some from the chat interface, which renders HTML and hands you typographic characters when you copy from the page. None of it is a watermark, and all of it breaks code, CSV imports and exact string matches the same way.
Can invisible characters prove that a text was written by AI?
No. A zero-width space or a curly quote tells you the text passed through a system that produces typographic output, which includes every chat interface, Word, Google Docs, most CMS editors and any web page. Human-written text copied out of a browser looks identical under inspection. The research on real text watermarking works by biasing which tokens a model picks, not by inserting characters, so it leaves nothing visible in a codepoint report. Treat "it has an em dash, so it is AI" as folklore.
Why does AI-generated text use em dashes and curly quotes?
Because its training data does. Published prose, journalism and books use typographic punctuation, so a model trained on them reproduces it, and the chat interface renders it as such. That is also why the pattern is a weak signal for authorship: any writer working in Word or Google Docs gets the same characters from autocorrect. What it does affect is everything downstream, since a curly apostrophe is not an apostrophe to a compiler, a CSV parser or a search index.
Does removing hidden characters change what an AI detector says?
No. Detectors score statistical properties of word and token choice, such as perplexity and burstiness, not the characters used for punctuation. Replacing an em dash with a hyphen changes none of the signals they measure. Cleaning text is worth doing because hidden characters break parsers, imports and comparisons, not because it influences a classifier.
How do I remove hidden characters from text I pasted?
Paste it here with the clean option on and copy the result: it strips the zero-width and control characters, turns non-breaking and exotic spaces into ordinary spaces, folds curly quotes and dashes to ASCII and normalises to NFC. Doing it yourself in JavaScript: text.replace(/[\u200B-\u200F\u2060-\u2064\uFEFF\u00AD]/g, "").normalize("NFC"). In Python: unicodedata.normalize("NFC", text) plus a filter that drops the Cf and Cc categories. Pasting through a plain-text editor removes the typography but leaves the zero-width characters, so it is not a substitute.
Is it safe to paste a password or an API key into an online character checker?
Only into one that analyses the text in your browser, and this is the tool category where it matters most: the usual reason to inspect a string is a password or a token that gets rejected even though it looks correct. Upload it to a server-side inspector and the credential is now in someone else's log. The analysis here is JavaScript running in your tab, nothing is uploaded or logged, and the page keeps working with the network disconnected. Open the Network tab in devtools while you type if you want to see that for yourself.
How do I type or insert a zero-width space on purpose?
In an editor that understands Unicode escapes, write it as the escape rather than the character: \u200B in JavaScript, Python and Java, ​ or ​ in HTML, U+200B typed then Alt+X in Word. On Linux the compose sequence is Ctrl+Shift+U followed by 200b. The one thing worth avoiding is copying it out of a web page, because you cannot see what you actually grabbed: a zero-width space, a zero-width non-joiner and a word joiner all look identical in the clipboard, which is how the wrong one ends up in a test fixture.
Why do two identical-looking strings not match?
Almost always one of three reasons. There is an invisible character in one of them, usually U+200B or U+FEFF. One of them uses a different space, typically U+00A0 instead of a normal U+0020. Or the two are different Unicode normalisation forms, where é is one codepoint in NFC and two in NFD. Paste both strings into the detector on separate lines: the annotated view shows the extra codepoint immediately, and the clean option folds both to the same NFC form.
Is a non-breaking space a real space?
Not to your code. U+00A0 renders like a space but is a different character, so a split on " " keeps it, a trim leaves it in place, and an equality check fails. It arrives through HTML entities, through word processors that insert it before units and punctuation, and through French typography rules that put one before a colon. In spreadsheets it is a frequent cause of numbers that refuse to parse: "1 234" with U+00A0 as the thousands separator looks like a number and is not.
What are Unicode tag characters?
The block from U+E0000 to U+E007F mirrors ASCII invisibly, one codepoint per character. Deprecated for their original purpose and rendered by nothing, they still survive copy and paste and still arrive intact at anything reading the raw string, including a language model. That makes them the standard carrier for hidden prompt injection: a human sees an ordinary sentence, the model reads an appended instruction. The report collapses a whole block into one finding and decodes it back to readable text.
What is the Trojan Source attack?
Trojan Source, published by Nicholas Boucher and Ross Anderson in 2021 as CVE-2021-42574, uses Unicode bidirectional control characters to make source code display in a different order from how the compiler reads it. A line that appears to be a comment can be executable code, and a permission check that looks correct can be inside a string. GitHub, GitLab and VS Code added warnings within days, and several compilers reject the characters outright. The ones to look for are U+202A to U+202E and U+2066 to U+2069.
How do I find invisible characters across a whole project?
In a terminal, "cat -A file" shows non-printing bytes, and grep with PCRE finds them in bulk: grep -rPn "[\x{200B}-\x{200F}\x{202A}-\x{202E}\x{FEFF}]" . works with GNU grep or ripgrep. VS Code has highlighted invisible and ambiguous characters by default since version 1.63, controlled by editor.unicodeHighlight, and ESLint's no-irregular-whitespace covers the space classes in JavaScript source. For a single string that already failed, pasting it here is faster because the report names the character instead of only marking that something is there.
What is the difference between NFC and NFD?
They are two normalisation forms for the same text. NFC composes: é is the single codepoint U+00E9. NFD decomposes: the same é is U+0065 followed by the combining accent U+0301. Both render identically and both are correct, which is why a filename created on macOS (NFD by default) can fail to match the same name typed on Linux (NFC). Normalising to NFC on input is the usual fix, and it is what the clean option here applies.