Notes › Tech Notes › The Keep-Set Shrinks to 66 Characters: Character-by-Character Readings of Three Encoding Modes over 128 Code Points, Plus a 9-Cell Encode/Decode Matrix

The Keep-Set Shrinks to 66 Characters: Character-by-Character Readings of Three Encoding Modes over 128 Code Points, Plus a 9-Cell Encode/Decode Matrix

This page leaves 71, 82 and 66 of the 128 code points untouched in its three modes; six replaces fold five symbols into percent form and turn the space into a plus, and 40 of the 45 runs in the 9-cell matrix come back unchanged.

One Chinese sentence pasted into three modes comes out as three strings

I lifted the six function bodies on this page out verbatim and ran them as they are in plain Node (NODE=v24.19.0, a plain-Node reading, not a browser one). The raw 你好 world is 10 code units; the component and uri modes both build the same 26-unit string %E4%BD%A0%E5%A5%BD%20world, while the php mode lands on 25. The whole gap is the space: a 1-unit + standing in for a 3-unit %20. Asking why PHP mode comes out a few characters shorter is a question you can only settle by feeding all 128 code points in one at a time. Every string quoted below is copied off what these chains actually return.

Across 128 code points, which characters does each mode leave alone

Group B of the probe feeds every one of the 128 code points of ASCII 0–127 into the three modes a character at a time, and an output equal to its input counts as kept by that mode. The component mode keeps 71 and escapes 57; the uri mode keeps 82 and escapes 46; the php mode keeps 66 and escapes 62. All three modes escape the same 46 characters, and all three keep the same 66 — that is the number in the title: beyond letters and digits only - _ . ~ survive.

  • uri keeps 11 more than component: # $ & + , / : ; = ? @.
  • component keeps 5 more than php: ! ' ( ) * (code points 33, 39, 40, 41, 42), which the php mode folds into %21 %27 %28 %29 %2A.
  • Of the twelve characters ;/?:@&=+$,#[], ten are kept only by the uri mode, while the two brackets [ ] are escaped by all three into %5B %5D.

The differences balance out as an account: 66 = 71 − 5 and 82 = 71 + 11, and the 16 characters uri keeps beyond php are simply those two groups added together. Read as a diagram, the php keep-set is the component keep-set minus its five symbols, and the uri keep-set is the component keep-set plus its eleven separators; the shared core is what no mode here ever escapes.

Solid fill = kept, outline = escaped component kept 71 escaped 57 uri kept 82 escaped 46 php kept 66 escaped 62 php gives up 5, uri adds 11

Figure 1: the width of the three keep-sets measured over the 128 code points

What the six replaces inside phpUrlencode take away

The function body is encodeURIComponent followed by six chained replaces. Group C feeds in !']()()%20a~_-., +, 16 code units raw; encodeURIComponent first turns it into 24, then the next five steps each fire once and each add 2, so the length walks 24 → 26 → 28 → 30 → 32 → 34, and the last step %20 → + fires once, trading 3 units back for 1 and landing 34 → 32. Across both chains ~ and - _ . are never touched, and those five steps over five symbols are exactly what narrows the keep-set from 71 down to 66.

Moving %20 → + to the front as a control (group H) is worth recording: probing all 128 single characters one by one, the two orders diverge 0/128 times, and whole strings come back character for character equal as well. No reordering difference is observable on the measurable samples of this page, so the sentence stays inside the measured frame.

When the input already spells out %21 the page rewrites its own work, and that is the only genuine second-encode path here: phpUrlencode("%21") yields %2521, phpUrlencode("%20") yields %2520 — a hand-written %20 is never eaten by step 6 into a +, only a space this run actually produced turns into one — and phpUrlencode("+") yields %2B. Feeding "a b!" back three times gives pass1 = a+b%21, pass2 = a%2Bb%2521 and pass3 = a%252Bb%252521, and phpUrldecode peels exactly one layer per pass. The six functions on the URL encode and decode page are stateless and pure: the same input called 1000 times returns the same output every time.

Three encoders × three decoders: the nine-cell matrix

Group D pairs each of the three encode modes with each of the three decode modes over five samples — 你好 world, "a b", "a+b", "x=1&y=2" and 50% — five runs per cell, so 45 measurements across the 9 cells: 40 come back unchanged, 5 do not, 0 throw. The diagonal is the comfortable part of that table, since a string encoded and decoded by one mode comes home; everything interesting happens off it, where one mode's output is another mode's input.

Encodercomponent decodeuri decodephp decode
component5/55/55/5
uri5/55/54/5
php3/53/55/5

Every break sits on the plus sign. The php mode encodes 你好 world as %E4%BD%A0%E5%A5%BD+world, and a component or uri decoder hands back 你好+world: decodeURIComponent returns a bare + unchanged, which is the cause of the 4 non-recovering runs in the matrix. The single break in the uri|php cell is a+b, whose + the pre-substitution inside the php decoder converts into a space and turns into "a b". As for 50%, all nine cells restore it.

No trim on the way in, trim first on the way out

urlEncode reads the raw value without cutting anything, while urlDecode trims before it decodes. That asymmetry is counted over two classes, whether the value carries whitespace at either end (group E: 8 inputs × 3 modes = 24 rows). In the encode-then-decode direction the result is 24/24 unchanged, because an encoded string never leaves whitespace at its ends for trim to catch. In the decode-directly-then-re-encode direction the padded class recovers 0/15 and the clean class 9/9, and across the 24 rows trim changes the decoded result in 15/24 cases. JS trim sweeps away Tab and the full-width space U+3000 along with ordinary blanks, so " a b " leaves the php mode as ++a+b++ with the pluses doubled up, while Tab is %09 in all three modes and never rides the space path at all.

When it fails this page sends one fixed sentence

On the decode side, 5 malformed % sequences (%E4%B8, %ZZ, %, 50%, %2) × 3 modes give 15 rows that all throw URIError, and every e.message reads URI malformed; what the catch actually emits is the fixed whole sentence for a decode failure, with no e.message inside it. On the encode side URIError can only be raised by a lone surrogate: \uD800, x\uD800y and \uDF00 × 3 modes give 9 rows that all throw, and the toast is assembled as the encode-failure line followed by URI malformed. A proper surrogate pair, the one that opens with \uD83D, encodes in all three modes to %F0%9F%98%80, so what gets stopped is a half pair rather than non-ASCII text. Neither catch writes the output boxes, and browser presentation is recorded as not measured.

Judged line by line against the intro

The url-encode entry starts on line 61 of config/tools.php and its intro sits on line 70 (1639 code units / 2129 bytes). Judging it claim by claim gives 13 verdicts: 8 consistent, 2 in conflict, 3 partially consistent. The first conflict is the claim that encodeURI leaves the space alone — measured, encodeURI(' ') = %20 — and the second is real-time two-way conversion: the template has no oninput anywhere, and a conversion only fires from the three onclick buttons. The compatibility wording is judged narrowly, only as far as the format features of this page's six replaces matching, and that is where this article stops about it. Splitting parameters belongs to the URL parser page and is not carried over here. Everything else in that intro is either confirmed by the runs above or left standing as the page's own description.

FAQ

Q: For one and the same Chinese sentence, why does PHP mode come out a few characters shorter?

The missing part is the space: the php mode encodes a space as the 1-unit + while the other two modes spend the 3-unit %20 on it, so 你好 world at 26 against 25 is exactly that difference. The rest of the gap sits in those 5 symbols ! ' ( ) *, which component keeps and php takes away.

Q: If php mode encodes and encodeURIComponent decodes, does the + ever come back as a space?

It never comes back. decodeURIComponent returns a bare + unchanged, measured at 3/5 in each of the php|component and php|uri cells, and 你好+world still carries the plus when it lands. Getting the original value back needs the php decoder, which first rewrites + as %20 and then decodes; the three cells on the matrix diagonal all read 5/5.

Q: My input already spells out %21 — will this page encode it a second time?

It will: phpUrlencode("%21") gives %2521, because the percent itself is pushed up into %25, and that is the only genuine second-encode path on this page. A hand-written %20 by contrast stays parked at %2520 and never becomes a +. The older article on how URL encoding works never ran this set of character-level measurements.

📎 Related articles

Decoding Runs Four Steps in a Row: Of 1194 Corpus Lines on the unicode-convert Page Only 798 Come Back Unchanged 2026-11-09 The Interface Order of the Eight Checkboxes Runs Backwards from the Code: Hard-Coded Eight-Step Order, Pairwise Swap Truth Table, and Code-Unit Metrics on the Text Cleaner Page 2026-11-09 The color picker parse gate accepts only six-digit hex, making the three-digit branch unreachable on the page 2026-11-08 Editing the Salt Changes Nothing Until You Click: Three-Segment Concatenation and Multiline Input Measured on the MD5 Page 2026-11-08 A 60-bit Timestamp Leaves 28 Positions on This Page: Shifts and Masks in the UUID Generator v1 Branch, Measured Millisecond by Millisecond 2026-11-08