Invisible Characters: URL Encoding, Entities, Fullwidth
"It looks fine on my machine" is the classic symptom of a character bug. Fullwidth digits, entities decoded twice, a plus sign in a URL and a dollar sign in a replacement all leave the glyph untouched and only change behaviour.
- Fullwidth digits and letters are almost impossible to tell apart by eye, yet form validation rejects them outright - only code points in U+FF01 to U+FF5E are fullwidth ASCII variants
- HTML entity decoding must be a single scan, because step-by-step replacement turns
<into a bare angle bracket and that is content tampering - URL encoding writes a space as
%20; a plus sign means space only in form encoding, and decoding never turns+back into a space - A dollar sign in the replacement text is always special regardless of mode, so a literal one has to be written twice
- One Chinese character is three percent-encoded groups and one emoji is four, which means the group count alone tells you the character class
#Symptom: identical appearance, different behaviour
Character bugs share one trait: they display correctly but behave incorrectly, because what changed is the bytes rather than the glyph. Comparing by eye will never find them; only a tool can expose the invisible part.
Four typical symptoms, each with a different cause:
| Symptom | Likely cause | How to confirm |
|---|---|---|
| The form says invalid, but it looks right | Fullwidth digits or letters | Convert fullwidth, read the change count |
The page shows < instead of a character | An entity decoded twice | Look for & in the raw string |
| The backend never receives the parameter | A plus sign not handled as form encoding | Replace plus with space, then decode |
| The replacement added or lost text | A dollar sign read as a capture reference | Inspect the replacement text |
#Fullwidth and halfwidth: nearly indistinguishable
A fullwidth digit and a halfwidth digit differ by a little width on screen and by a completely different character in code. Text copied from a PDF, Excel, a legacy system or certain input methods carries them often.
The rule is blunt: anything in U+FF01 to U+FF5E is a fullwidth ASCII variant, so subtracting 0xFEE0 gives the halfwidth form, and U+3000 becomes an ordinary space.
The fullwidth-halfwidth converter does it in one pass and reports how many characters actually changed - a count of zero means there was nothing to convert.
Conversion does not touch Chinese punctuation. The enumeration comma 、 (U+3001), the ideographic full stop 。 (U+3002), book-title marks, Chinese quotation marks and the em dash all sit outside that range and stay as they are. Meanwhile ,!?;:() are fullwidth ASCII and do get converted - check your punctuation afterwards.
This matters in interface design: in mixed Chinese and Latin text a fullwidth comma occupies a full character width while a halfwidth comma occupies half, so mixing them makes the visual rhythm of a paragraph lurch. Decide which set you want and convert in bulk rather than editing by hand.
#HTML entities: decode exactly once
Entity decoding has to be a single scan, because step-by-step replacement decodes twice - and that is not "one layer too many", it is tampering. &lt; should become <; a naive multi-pass turns it into a bare <.
Encoding covers four characters only: &, <, >, ". The order is fixed too: ampersand first, otherwise entities produced later get escaped by their own pass.
With the HTML entity encoder, two boundaries are worth knowing:
- A single quote is not encoded by default; add
'by hand when an attribute is wrapped in single quotes or JSON is embedded inonclick - Named entities go far beyond these four:
,©and—are left as-is, because the tool does not carry an entity table
Escaping prevents text from being parsed as markup. It is not complete XSS protection. Concatenating user input into attribute values, inline scripts or javascript: links each has its own rules. The safe approach is to hand the value to a DOM API such as textContent, or to a template engine that escapes automatically - not to build strings by hand.
#URL encoding: plus is not a space
In URL encoding a space is %20; a plus sign means space only in form encoding (application/x-www-form-urlencoded). That historical split is the most common reason a parameter never arrives.
With the URL encoder and decoder, the rules follow encodeURIComponent: letters, digits and - _ . ! ~ * ' ( ) pass through, everything else is split into UTF-8 bytes and written as %XX per byte.
That makes the group count informative:
- One Chinese character is three groups (
中→%E4%B8%AD) - One emoji is four groups (
%F0%9F%98%80)
Seeing groups in threes usually means the source was Chinese text.
Two boundaries to remember:
- You cannot encode a whole URL with it. The
:and/inhttps://a.com/pbecome%3A%2F%2Fand the link dies. Keep scheme, host and path as they are and encode only the query keys and values - Decoding never turns
+back into a space.a+bcopied from a query string decodes toa+b. For form semantics, replace+with a space yourself first
When Chinese parameters come back as garbage, check the original encoding first. Percent sequences copied from a GBK page are decoded here as UTF-8, which produces mojibake rather than an error - the tool will not guess, because a wrong guess is harder to debug than a failure.
#The dollar sign in a replacement
A dollar sign in the replacement text is always special, whether or not regex mode is on - because the operation ends up in String.replace. That is the usual reason a replacement loses characters.
With find and replace, three rules cover it:
- In plain mode, typing
$1does not insert a literal$1; it references the first capture group, and plain mode has none, so the result is empty - For a literal dollar sign, write
$$ - To insert the whole match, write
$&
Plain mode automatically escapes regex metacharacters in the search text, so 价格(元) matches literally. Regex mode does not escape, and a broken pattern reports the engine's own message instead of silently returning the input.
Write the pattern itself without /.../gi delimiters or flags - case sensitivity and replace-all are controlled by checkboxes, and anything written into the pattern is matched as literal characters.
#Which tool to use
| What you need | Tool | Key difference |
|---|---|---|
| Normalise fullwidth digits and letters | Fullwidth-halfwidth converter | Only U+FF01–U+FF5E; Chinese punctuation untouched |
| Check for leftover escapes | HTML entity encoder | Single-scan decode, numeric entities supported |
| Parse or produce percent encoding | URL encoder and decoder | + is not a space; invalid sequences raise errors |
| Bulk replace with dollar signs | Find and replace | Plain mode escapes metacharacters for you |
| Inspect code points and bytes | Base converter | Binary, hex and Unicode code points |
| Count characters precisely | Text counter | Counts code points, separates characters from bytes |
A sensible order: check the characters first (fullwidth?), then the escape depth (decoded how many times?), then the encoding rules (plus sign? percent sign?). Those three steps locate almost every "looks the same but is not" bug.
#Frequently asked questions
#The number I copied is correct. Why does validation fail?
Almost certainly fullwidth digits. 123 and 123 look nearly identical but have different bytes, so a \d regex, parseInt or a database column will all reject one of them. Run it through the fullwidth-halfwidth converter: the changed-character count tells you immediately whether this was the problem.
#The page shows a literal <. How do I fix it?
The string's ampersand was escaped to &, so the browser decodes once and leaves <, which then renders as text. Either escape one time fewer, or only escape the four necessary characters when storing. Decode once with the HTML entity encoder to see what the original content really was.
#Should a space in a URL be %20 or +?
It depends on context. In a path only %20 is valid; in a query string, form encoding accepts +, but under strict RFC 3986 semantics + is an ordinary character. Across systems, %20 is never wrong, while + requires the receiver to parse form rules.
#Why did $1 become empty in my replacement?
Because plain mode has no capture groups, so $1 references nothing. Either switch to regex mode with a real group such as (\d+) and use $1, or escape the literal text as $$1. The behaviour comes from String.replace, not from the tool.
#Why is one Chinese character so long in a URL?
In UTF-8 a common Chinese character takes three bytes, and each byte becomes one %XX group. That is the price of UTF-8, paid in exchange for one encoding shared by every language. GBK uses two bytes per character but travels far worse across systems.
Tools used in this guide
RFC 3986 URL percent-encoding
Open tool →HTML Entity EscapeConvert < > & HTML entities
Open tool →Full/Half WidthConvert full-width characters to half-width and vice versa
Open tool →Find & ReplaceFind and replace with plain text or regex modes
Open tool →Base ConverterConvert between base 2, 8, 10, 16, 36 and 64 in real time
Open tool →