Polyatic Unicode Inspector
All tools

Reveal hidden & confusable Unicode

Names and locates every invisible, bidi-control and confusable character in your text.

Your text never leaves this device. No upload, no server.

Text to inspect

…or drop a text file here — read on your device, never uploaded. UTF-8, up to 128 KB.

0 code points · 0 hidden · 0 confusable

Annotated view

Hidden characters become a labeled chip; confusables stay visible but highlighted. Hover any character for its name.
Paste or type text above to see it annotated here.

Findings

Waiting for text — every hidden or control character will be listed here.

Confusables & scripts

Waiting for text. Paste or type something above and the confusable / mixed-script verdict for it appears here.

Every character

Not just the suspicious ones — every code point, with its Unicode name, block, category, script and five encodings.
0grapheme clusters
0code points
0UTF-16 unitsString.length
0UTF-8 bytes

Paste or type text above and every character in it is described here — ordinary ones included.

Plain text

Word and chatbot spaces, dashes and quotes swapped for keyboard ones. Live — untick what to keep.

Paste text above to see what would change.

Typographic characters to replace

Copy as code

Your text as an escape sequence you can paste into source code or a bug report, so an invisible character shows up as \u200B instead of nothing. The form you pick, and the switch, travel with Copy link.
Which characters to escape

JavaScript astral form: a character above U+FFFF, such as 😀, is written as one code-point escape, \u{1F600}, not as the surrogate pair \uD83D\uDE00 (valid in ES2015 and later, i.e. every current browser and Node).

Forms: JavaScript \u200B, astral \u{1F600} · JSON \u200B, astral as a surrogate pair \uD83D\uDE00 · Python in the lowercase forms ascii() prints: \xe9, \u200b, \U0001f600 · HTML &#x1F600; with & < > " always escaped · CSS six-digit \01F600, plus one extra space when a real space follows (CSS eats one) · URL is encodeURIComponent and always encodes everything. Quotes, backslashes and control characters are always escaped, so each string literal is valid as pasted.

Strip & clean

Choose which classes to remove, then strip. No-break spaces are normalized to a regular space, not deleted.
Character classes to strip

Audit — what changed

Normalization forms

The other reason two identical-looking strings never compare equal: café can be spelled with é as one code point (U+00E9) or as e + U+0301 — the form macOS puts in filenames. Measured live, no button.
Nothing to normalize yet. Paste text above and all four forms are measured live — no button to press.
NFKC and NFKD are lossy compatibility folds. They replace characters instead of re-spelling them, and the original cannot be recovered from the output: U+FB01 fi becomes the two letters fi, U+2460 ① becomes the digit 1, and full-width U+FF21 A becomes plain A. Never run them over data that has to come back byte-for-byte — passwords, signatures, file contents, anything you will re-encode or compare to a stored hash. NFC and NFD are lossless: they only change how the same characters are spelled.
Normalization form to output

Use NFC — it is the form to use for ordinary text and for comparing strings, and it is what the web platform, most databases and most languages expect. NFD is useful when you want to inspect or strip the accents themselves; the two K forms are for matching and searching only, never for storage.

Audit — what normalizing changes

Use it in your own code

The same scan, confusable detection and strip as a Node module — MIT, no dependencies, runs offline.
npm install @verifyhash/unicode-inspector

Three lines — the zero-width space in a lookalike host

const { scan } = require('@verifyhash/unicode-inspector');
const { findings } = scan('pay\u200bpal.com');
console.log(findings.map(f => `${f.hex} ${f.name} at index ${f.index} (${f.class})`).join('\n'));

prints

U+200B ZERO WIDTH SPACE at index 3 (zero-width)

What the package does not include: this page — the annotated view, the per-character table, the permalink, open/save, the IDN block, and the Unicode 17.0 character-name list (that list is page-only: it ships as this page's own data file, not in the tarball), because the published exports are scan, strip, detectConfusables, skeleton, CLASSES and UNICODE_VERSION and none of them converts a host to its xn-- punycode form (that block uses your browser's own WHATWG URL parser, which the package never wraps); the pinned UTS #39 confusable table is in the tarball — this page's copy of it is generated from that same file and its map is identical entry for entry.

@verifyhash/unicode-inspector on npm · version 0.1.0 · MIT · also listed with our other libraries on Polyatic libs.

What it can — and can't — tell you

What it does. The inspector names every hidden codepoint in your text — zero-width spaces, word joiners, BOMs, bidirectional controls, no-break spaces, soft hyphens, variation selectors — with its official Unicode name, its position, and its class. It also flags confusable homoglyphs (the pаypal problem, where the а is Cyrillic U+0430, not Latin a) and tells you when a single word or identifier mixes writing systems. Stripping is audited: you see exactly what was removed, normalized, or deliberately kept.

Find a character by its name. Type its name into the Find by name box at the top of the tool, for example snowman, em dash, check mark or degree. Matching characters are listed best first: an exact name, then names containing every word you typed, then partial matches. Each row shows the character, its U+ code point and its block, with Copy to put it on your clipboard and Inspect to add it to the text box. The search covers the 33,304 names Unicode 17.0 lists one by one, plus the formal aliases of control characters (escape finds U+001B) and the corrected spellings Unicode publishes for misnamed characters. Direction words are folded, so arrow right finds → U+2192 RIGHTWARDS ARROW. Equally good matches put about 2,000 everyday characters (punctuation, arrows, maths, dingbats, common emoji) ahead of rare ones. Typing a code point, U+2603, jumps straight to it. Only the search phrase, never the text in the box, is mirrored into the address as ?find=, so a link such as /unicode-inspector/?find=em%20dash opens with — U+2014 at the top; like any query string, that phrase travels with the page request. Limits: names are official English, so trademark finds nothing while trade mark finds ™, and the 100,000-odd ideographs and Hangul syllables, whose names are a formula plus the code point, are not word-searchable: type the code point or paste the character instead.

Copy a blank or invisible character. The Blank chars button (or a link ending in ?blank=1) opens a list of eight characters that look empty: U+3164 HANGUL FILLER, U+2800 BRAILLE PATTERN BLANK, U+200B ZERO WIDTH SPACE, U+00A0 NO-BREAK SPACE, U+2007 FIGURE SPACE, U+115F HANGUL CHOSEONG FILLER, U+FFA0 HALFWIDTH HANGUL FILLER and U+180E MONGOLIAN VOWEL SEPARATOR. Each Copy button puts exactly that one code point on the clipboard, nothing before or after it. No list can promise a character is accepted by a given site: apps change what they accept and some strip these, so if one is refused, try the next, and paste anything you copied into the text box to see exactly which character it is. They are blank for different reasons, and that decides where they survive. U+00A0 and U+2007 are real spaces (category Zs, White_Space), so anything that trims spaces trims them; U+200B is a zero-width format character that many filters remove on sight. The three Hangul fillers are letters (Lo) that draw nothing, and U+2800 is a symbol — an empty braille cell — so a whitespace check does not catch them. Five of the eight are Default_Ignorable_Code_Point in DerivedCoreProperties.txt (Unicode 17.0.0), which tells software it may skip them when drawing; U+2800, U+00A0 and U+2007 are not. U+180E was a space until Unicode 6.3 (2013) moved it to the format category, so it is no longer whitespace anywhere that follows a current Unicode version. The dashed box beside each row is the width your own browser and font give it, measured on the page: zero for U+200B, a fraction of an em for the spaces, and for the Hangul fillers whatever your font decides.

Every character, not only the suspicious ones. The Every character panel describes each code point in input order: the glyph, U+XXXX, the Unicode general category (Lu uppercase letter, Nd digit, Mn combining mark, So other symbol, Cf format…), the script, and the same character written five ways — UTF-8 bytes, UTF-16 code units, an HTML entity, a JavaScript escape and percent-encoded UTF-8. So 😀 reads U+1F600, So, F0 9F 98 80, D83D DE00, &#x1F600;, \u{1F600}, %F0%9F%98%80. Category and script are read live out of your browser's own regular-expression engine with \p{…} property escapes, so no extra data file ships and nothing here can drift out of step with the Unicode version the browser actually implements. Above the table sit four counts of the same text — grapheme clusters, code points, UTF-16 units (String.length) and UTF-8 bytes — which together are the whole answer to "why does my string length disagree with what I see". noël typed with a combining accent (e + U+0308) is 4 grapheme clusters, 5 code points, 5 UTF-16 units and 6 UTF-8 bytes; the composed ë (U+00EB) makes the identical-looking word 4 / 4 / 4 / 5. Grapheme clustering needs Intl.Segmenter, so in a browser without it that one number reads n/a instead of a wrong number. The table shows the first 500 code points, says how many it is holding back, and has a button for the next 500 — that cap is what keeps a 128 KB paste re-rendering at the ~0.7 s quoted below rather than building 131,000 rows. Does it name every character, including emoji and Chinese? Yes. Every assigned code point gets its official Unicode 17.0 name, its block and its general category: 😀 is GRINNING FACE in the Emoticons block, 你 is CJK UNIFIED IDEOGRAPH-4F60 and é is LATIN SMALL LETTER E WITH ACUTE. A control character has no name in Unicode, so it shows its formal alias instead — U+0000 reads NULL. Unassigned, private-use, noncharacter and surrogate code points say which they are rather than borrowing a name.

Where the names come from. They are the Unicode Character Database 17.0.0 — UnicodeData.txt for the 159,866 assigned characters, NameAliases.txt for the control aliases and the 39 published spelling corrections (U+FE18's name really is spelled BRAKCET; the table shows the correction under it), and Blocks.txt for the block, used under the Unicode License v3 (licence text and the sha256 of each file). Of those names, 126,497 are not stored at all, because the standard defines them by rule: the 101,984 CJK unified ideographs, the CJK compatibility ideographs and the Tangut, Khitan Small Script, Nushu and newer Egyptian hieroglyph characters are named after their own code point, and the 11,172 Hangul syllables are spelled out from their jamo as section 3.12 of the standard prescribes (가 U+AC00 is HANGUL SYLLABLE GA, 힣 U+D7A3 is HANGUL SYLLABLE HIH). The other 33,304 names ship in one data file of about 160 KB compressed that is fetched after the page has drawn — each name cell reads “loading names…” for that moment — and the offline worker keeps it for the next visit. A test decodes the name of every one of the 1,114,112 code points and compares it with UnicodeData.txt. One limit worth stating plainly: a name identifies a code point, not a meaning. 你 is named by its number, not translated, and an emoji sequence is listed as the code points it is built from — 👍🏽 is THUMBS UP SIGN followed by EMOJI MODIFIER FITZPATRICK TYPE-4 — because Unicode names the parts, not the combined picture. The Script column still covers only the 16 scripts this page tests, and the two-letter category column comes from your browser's own tables, so for a character newer than the browser it can read Cn while the spelled-out category under the name gives the Unicode 17.0 answer.

Getting the escape sequence for an invisible character. Paste the text and use the Copy as code panel: it shows the string as a JavaScript, JSON, Python, HTML, CSS or URL literal with each flagged character escaped, so a zero-width space becomes \u200B. A switch escapes everything outside printable ASCII instead. The result is a literal that survives being pasted anywhere: pay+U+200B+pal.com becomes "pay\u200Bpal.com", which a code reviewer can see and a test can assert on. Pick the form for where it is going. JavaScript for JS/TS source and the browser console (\u{1F600} needs ES2015). JSON for config files, API payloads and fixtures: it has no code-point escape, so an emoji becomes the pair \uD83D\uDE00. Python uses the lowercase escapes that ascii() prints (repr() prints the same for invisible characters), so it lines up with a traceback. HTML character references for a page or template, CSS for a quoted string such as a content: value or an attribute selector's value, and URL for a query parameter or a link in a bug tracker. The default escapes only what is flagged above, leaving the readable part readable; switch to Everything outside printable ASCII when the text has to go through a channel that mangles non-ASCII bytes, such as an old terminal, an email header or a Latin-1 database column.

The same word stored two ways. When two strings look identical and still compare unequal, an invisible character is the first suspect and spelling is the second. café can be c a f + U+00E9, or c a f e + U+0301 COMBINING ACUTE ACCENT — four code points or five, one picture. macOS stores filenames the decomposed way, Linux and Windows store what you typed, and a database column, a URL and a Git index may each hold a different one. The Normalization forms panel measures all four forms live: whether your text is already in each one, how long it becomes, and exactly which positions change. Use NFC for storage and comparison. NFD is the form to reach for when you actually want the marks separated — stripping accents is s.normalize('NFD').replace(/\p{M}/gu,'') and nothing else. Two singletons are worth knowing about because they surprise people: U+212B ANGSTROM SIGN and U+2126 OHM SIGN are not stable — NFC rewrites them to U+00C5 and U+03A9, so a unit string can change under a normalization you thought was a no-op.

Why NFKC and NFKD are not a stronger NFC. The K forms apply compatibility mappings, which replace a character with something judged equivalent in meaning rather than re-spelling the same character: U+FB01 fi becomes the two letters fi, U+2460 ① becomes 1, full-width A becomes A, U+00B2 ² becomes 2, and U+2168 Ⅸ becomes IX. None of that is reversible. They are the right tool for a search index or a fuzzy username-collision check, and the wrong tool for anything you will store, sign, hash, or hand back byte-for-byte — which is why this page prints the lossy label on the K rows rather than burying the caveat down here.

Opening a file. The picker and the drop box read the file on your own machine with the browser's own FileReader and decode it as UTF-8; nothing is uploaded and no request is made, which is the same promise the paste box carries. The ceiling is 128 KB (131,072 bytes), about 3,000 lines of ordinary source, and a file over it is refused by name, with its size, rather than silently cut short. That ceiling exists because the annotated view rebuilds an element for every character it marks, on every change: measured on this page in headless Chrome on a modest cloud VM, re-annotating 128 KB takes roughly 0.7 seconds, 256 KB roughly 1.3, and 512 KB roughly 2.4, and every later keystroke pays that cost again. Anything that is not UTF-8 text is refused by name too: a NUL byte, a known binary signature (PNG, JPEG, GIF, PDF, ZIP, gzip, ELF, Mach-O, SQLite, Ogg, RIFF), or a byte that cannot start or continue a UTF-8 sequence stops the read, and the page names what it saw instead of filling the box with mojibake, so a file saved as Windows-1252 is reported as not-UTF-8 at the offending byte offset rather than quietly mangled. Two deliberate details: a UTF-8 BOM at the start of the file is kept and reported as U+FEFF instead of being swallowed by the decoder, because on this page a BOM is a finding; and if a file decodes cleanly but already contains U+FFFD REPLACEMENT CHARACTER, the page says so, because that damage is in the file itself and not something the decoder did. Saving it back. Save cleaned text and Save normalized text download exactly what the box above each button shows — the same code points, encoded UTF-8, with no BOM added, no trailing newline added and no CRLF rewriting — so a file you open, strip and save is byte-identical everywhere you did not ask for a change. The download borrows the name of the file you opened and inserts a suffix: notes.txt comes back as notes-cleaned.txt, data.csv as data-cleaned.csv, and the normalization box uses whichever form is selected — notes-nfc.txt, -nfd, -nfkc, -nfkd. Text you pasted has no name to borrow, so it saves as cleaned.txt or normalized-nfc.txt. The file is built in this tab and handed straight to your browser's own download machinery; nothing is sent anywhere, which is the same promise opening a file carries. Each button stays disabled until its box actually holds something, and if the browser refuses to create the file — a locked-down WebView, a download the operating system blocks — the button says Save failed and tells you so in the line beside it, rather than looking like it worked. Pasting remains the primary input: typing in the box replaces whatever a file put there, and nothing is remembered between page loads.

Why ordinary Arabic and Hindi are not flagged. A word written entirely in one writing system is Single Script under UTS #39, which is the definition of not-a-spoof. The page splits your text into identifier-like tokens first (letters, marks and digits plus the joiners . - _ @ + /), takes each token's script set from its letters, and counts a table look-alike as a signal only when its own token mixes scripts. Arabic and Devanagari letters that happen to have a Latin look-alike in the table are therefore reported only when they sit next to another script. Japanese and Korean are special-cased the way UTS #39's Highly Restrictive profile does it, so Han + Hiragana + Katakana in one token is not treated as mixing.

The plain reading. When a token is flagged, the panel also prints it with every look-alike put back to the plain letter it imitates, and hands you that text on a Copy button: аpple.com with a Cyrillic а reads apple.com, and gооgle.com with two Cyrillic о reads google.com. That is a reading of the string you pasted — what it could be mistaken for — and not a claim about who owns or sent it. It is bounded twice over: a character is put back only when the pinned table gives it a single-character prototype in the token's own dominant script, so U+093F DEVANAGARI VOWEL SIGN I (whose prototype is the Bengali ি) is flagged and then left exactly as you typed it; and the substitution is not a UTS #39 skeleton. A skeleton is a comparison key, not a rendering — it folds ASCII as well, mapping m onto the pair rn, so printing one would put a word nobody typed in front of you.

The address your browser really opens. A host name never travels as Unicode. Before the DNS query, before the TLS certificate check and before anything lands in a server log, the browser converts every non-ASCII label of the host to ASCII — punycode, defined by IDNA (RFC 3492) and UTS #46. Paste a web address, or just a bare host name, and the block under the Confusables panel shows what that conversion produces, label by label: аpple.com is requested as xn--pple-43d.com, micrоsоft-lоgin.com with three Cyrillic о as xn--micrsft-lgin-7fkbe.com, and the perfectly ordinary German müller-gmbh.de as xn--mller-gmbh-9db.de — which is the reason the block describes what it sees and stops there: a punycode host is also how every legitimate non-English domain works. The conversion is done by your browser's own WHATWG URL parser, the same code it navigates with, so the string shown is the string it would ask for, port and path included. A host that is already plain ASCII produces no block at all, because nothing is hidden in it. And nothing is fetched to work any of this out: the domain is never looked up, never resolved and never contacted.

Plain text from Word or a chatbot. How do I turn AI or Word text into plain text? Open the Plain text panel (the Plain text button, or a link ending in ?mode=plain). It counts each class of typographic character in your text and replaces the ticked ones live: narrow no-break, no-break, thin and hair spaces become a regular space; zero-width spaces, joiners and soft hyphens are removed; en dashes and non-breaking hyphens become a hyphen; curly quotes become straight quotes; and an ellipsis becomes three dots. Em dashes are kept unless you tick that box, because an em dash is correct punctuation. Ticked, cat—a tabby becomes cat - a tabby. Why bother: git commit -m “fix” pasted from a chat fails in a shell because the quotes are curly, a CSV column split on spaces misses a U+00A0, and "9:30 AM" === "9:30 AM" is false when one side has a U+202F. A zero-width joiner between two emoji (the glue inside 👩‍💻) is left alone, since removing it splits the emoji. Limits: Persian, Urdu and several Indic scripts use U+200C as a real spelling mark, and French typography wants a narrow no-break space before ; : ! ? — untick those classes for such text. Characters outside these ten classes, such as bidi controls or homoglyphs, are the job of Strip & clean and the confusable check above.

Does ChatGPT hide a watermark in its text? No public evidence shows that it does. The characters the Plain text panel replaces (narrow no-break spaces, no-break spaces, em and en dashes, curly quotes, the one-character ellipsis, zero-width spaces) are ordinary typographic characters that Word, Google Docs, macOS autocorrect and locale data also produce: since ICU 72 (Chrome 110, Node 20), formatting an English time gives 9:30 AM with a U+202F narrow no-break space before AM. We know of no OpenAI statement that ChatGPT marks its output with special characters. Removing them does not make text pass or fail any AI detector, and this page does not claim it does; it only makes the text plain.

When you'd use it. Three jobs: debugging a string that refuses to compare equal or parse (a stray U+200B or U+00A0 is the classic invisible culprit); security review of source code and commits for Trojan-Source bidi attacks (CVE-2021-42574); and phishing analysis of suspicious domains, usernames, and display names. Each is a different class of invisible. In the debugging case the culprit is usually a space that is not a space, but variation selectors are the quiet second suspect: U+FE00–U+FE0F and the U+E0100 block change nothing you can see, yet ❤️ (U+2764 U+FE0F) and ❤ (U+2764 on its own) are different strings, different byte lengths and a failing equality test. In the Trojan-Source case (CVE-2021-42574) the attack is U+202E RIGHT-TO-LEFT OVERRIDE and its siblings reordering what a human reviewer reads while leaving untouched every byte the compiler compiles — a comment can be displayed as code, and code as a comment. In the phishing case it is confusable homoglyphs and mixed-script spoofs: a display name or domain that renders identically to one you trust is the entire trick. Each of those is named and located by position in the Findings table above, and the page arrives with an example already inspected, so the shape of an answer is on screen before you paste anything of your own.

Honest limits. Confusable detection is data-table-bound: a character is flagged only if it appears in the pinned Unicode UTS #39 table (v17.0.0, 6,565 mappings), so novel or un-tabled look-alikes pass unflagged, and two "confusable" glyphs may render distinctly in your particular font. Plain ASCII is the reference alphabet and is never itself reported — rn vs m style ASCII-on-ASCII tricks need a skeleton comparison of the two whole strings, not this per-character scan. The token rule has a known blind spot: a whole-script confusable — an all-Cyrillic раураӏ with no Latin character in it — is deliberately not flagged, because every letter of the ordinary Russian word Москва also has a Latin look-alike in the pinned table (М→M, о→o, с→c, к→ĸ, в→ʙ, а→a), so a rule that caught the fake would fire on Moscow too. Separating them needs a lexicon this offline page does not carry. Tick Show every table look-alike in the Confusables panel to see every match the table found, signal or not.

Deliberate strip behavior. No-break spaces are normalized to a regular space, not deleted (deleting one would silently join words). A zero-width joiner that glues a well-formed emoji sequence (👨‍👩‍👧) is kept by default so the emoji survives — it is still named in the findings and listed in the audit, never silent. And lone UTF-16 surrogates are not flagged at all: they are category Cs, outside the pinned control/format table.

Where your text goes: nowhere. Your text never leaves this device. There is no server and no upload — every check runs in your browser and keeps working offline once the page has loaded. The page does load one cookieless pageview counter from stats.dankdev.com; it records the URL, never what you paste. That is the whole network story: the findings table, the confusable scan, the normalization forms and the strip are all computed here, and a file you drop or open is read with the browser's own FileReader rather than sent anywhere. The page keeps nothing between visits either — no cookies, no local storage, no history — so a reload puts the box back to the example and leaves no trace of what you pasted. If you would rather not take any of that on trust, this page is a single static document: read its source, or save it and run it from a file with the network off.

Offline later: once you have opened this page with a connection, it reopens and works with none — your browser keeps a copy. Not saved: the very first Polyatic page you ever open (open it again while online), and any page or link you have never opened.