Notes › Tech Notes › text-line-ops: delete right gives an empty string, cut right hands back the whole line

text-line-ops: delete right gives an empty string, cut right hands back the whole line

Stripping the first 2 characters from an exported list left half a box on the line holding a rare character. Running the template script verbatim in Node shows substring counting UTF-16 code units, delete-right giving an empty string while cut-right returns the whole line, and min referenced nowhere in the code.

Deleting the first 2 characters of every line left a box at the start of one

A list exported from a back office, one item per line, needed the leading 2 characters of each line removed. Most lines came out clean. The one line carrying a rare character began with an unrecognizable box plus what was left, and the emoji column behaved the same: deleting 1 character looked like deleting half of one. So the page script was cut out verbatim and run as-is in Node. The template measured 8018 bytes across 175 lines; its one script block held 2708 UTF-16 code units and 3176 bytes, and the entire behavior of delete and cut rests on a single substring call.

It counts UTF-16 code units, not glyphs

All four branches of process() in text line ops go through String.prototype.substring, so the unit is a UTF-16 code unit. Every sample was measured character by character:

  • "苹" takes 1 code unit and 3 bytes in UTF-8; all five fruit names in the preset sample are this kind;
  • the rare character sits in the supplementary plane at U+20000, it is a surrogate pair written \uD840\uDC00, so it takes 2 code units and 4 UTF-8 bytes, hex f0a08080;
  • a single emoji likewise takes 2 code units and 4 bytes;
  • concatenation: the rare character followed by 甲乙, or that pair in front of it, has length 4 either way; an emoji joined to abc has length 5 at both ends.

n set to 1 cuts through half a surrogate pair

Delete takes the left side. On the string where the rare character leads and 甲乙 follows, n set to 1 outputs 3 code units opening with an orphan low surrogate, recorded by the probe verbatim as "\udc00甲乙". The same string through cut-left with n=1 reaches only the left half of the pair and emits the single code unit \ud840. The right side mirrors this: deleting 1 from the right leaves an orphan high surrogate at the line end, cutting 1 returns \uDC00, and the emoji string with a left delete of 1 gives a 4 code unit string opening with \ude00. Only n=2 swallows the pair whole. An orphan surrogate is still a legal code unit in JS, so length keeps counting it, but any UTF-8 encoding turns it into U+FFFD: measured, Buffer.from("\uD840", "utf8") returns 3 bytes, efbfbd, and the low half behaves the same.

Past the bound: delete right gives an empty string, cut right returns the whole line

Only one of the four branches writes an explicit bound check: delete right at template L138, which reads n >= line.length ? "" : line.substring(0, line.length - n). The other three (L136 delete left, L144 cut left, L146 cut right) lean entirely on substring clamping, where a negative start becomes 0 and an end past the line becomes the line length.

Same three-line input, same n: delete clears, cut returns the short line untouched input line delete left delete right cut left cut right ABCDE E A ABCD BCDE AB empty empty AB AB whole line A empty empty A A whole line Only delete right checks bounds in the source; the rest use substring clamping

Figure 1: four branches measured on three input lines of unequal length under one n

Anyone keeping only the last few characters gets the full text back on a short line, nothing removed; the delete branches stay safe when the goal is to clear a line completely.

Both n defaults are 2, and min never takes part

Read L34 and L44 side by side: the value attribute of both number inputs is 2, and they differ only in min, min="0" for delete and min="1" for cut. The claim that cut deletes one fewer by default mistakes min for the default value. Across the script the identifiers min, max, step, validity and checkValidity appear 0 times in total, and process() never queries validation state. The first screen confirms it: the preset sample is 5 lines of exactly 2 code units each, direction left, the top option in both selects, substring(2) empties every line, the output is 4 newline code units plus 5 empty lines, and the statistics still report 处理行数 5.

An || 0 after parseInt stops neither negatives nor letters

Both n inputs are read with parseInt(..., 10) || 0, and || 0 only catches NaN and 0. The left column below is the string fed into value verbatim; then come the normalized result and the measured behavior of the single line "ABCD" (4 code units) across the four branches:

typed innormalizes todelete leftdelete rightcut leftcut right
empty string0ABCDABCDclearedcleared
"abc"0ABCDABCDclearedcleared
"2.7"2, cut off before the decimal pointCDABABCD
"1e3"1, it stops at the letter e, not 1000BCDABCAD
"-3"-3, negatives pass straight throughABCDABCDclearedcleared
20 nines written in a row1e20clearedclearedABCDABCD

A trailing newline adds one line to the count, and blank lines always reach the result

Source line L127 is input.split("\n"), the loop body runs the branch on every element and pushes it, nothing skips an empty string, and the 处理行数 counter at L167 simply reads lines.length. Five checks, all delete left with n=1 unless noted:

  • five sample lines with no trailing newline: split yields 5 parts, the count says 5, matching the content;
  • 苹果\n香蕉\n with one more newline at the end: split yields 3 parts and the count says 3, while the real content is 2 lines, off by 1;
  • empty input: split returns 1 part and the count says 1, content 0 lines;
  • "x\ny\n\n" is 2 content lines dragging 2 empty tail lines: the count says 4, content 2, off by 2;
  • the middle blank line of "a\n\nb" reaches the result unchanged and line positions still pair up; cut right with n=3 on "hello\n\nworld" also gives 3 lines with the middle one blank.

A count that does not add up does not mean a line was emptied; clearing blank lines before line-level work is less trouble, and that reasoning is in cleaning blank lines and whitespace.

Switching branches does not reset parameters, leftover values wait in their own branch

switchMode only rewrites the current branch variable, moves the active class between buttons, writes style.display on each of the four parameter divs, refreshes the hint text and calls process() once; no line in the function assigns a value to an input control. The probe logged value writes per element: after 6 clicks cycling all four tabs and back, the 5 inputs of the prefix and numbering branches show 0 writes each. The parameter DOM stays mounted, hidden only by display. How those two branches are filled is the subject of the prefix and line number article, which this one does not touch.

Who owns a leftover value: type "ABCDEFGH" (8 code units, direction explicitly left), set del-n to 5 in the delete branch and the output is "FGH". Switch to cut, which reads its own cut-n initial value 2 and returns "AB"; that 5 never crossed branches.

FAQ

Q: why does deleting 1 character damage a character you never touched?

substring counts UTF-16 code units, and a supplementary-plane Han character or an emoji occupies 2 of them, so n=1 removes only half. The orphan surrogate left behind, passed through UTF-8 encoding once, becomes the 3 bytes efbfbd, and the original character cannot be rebuilt.

Q: keeping only the last n characters leaves short lines uncut, why?

On the right side, when n exceeds the line length the start index goes negative and is treated as 0, so the whole line returns unchanged; the same length through the delete branch gives an empty string. To clear a short line instead of keeping it, you have to pick the short lines out yourself first.

Q: n holds a negative number or letters, so why is there no warning?

The read statement is only parseInt(..., 10) || 0, so an empty string and letters normalize to 0 while negatives pass through untouched; min is referenced 0 times in the script and takes part in no decision. Delete treats a negative as a no-op, cut treats it as a clear, and neither branch raises a warning.

📎 Related articles

The Count Reads 5 or 1: Three Switches and Zero-Width Matches Measured in Batch Find and Replace 2026-11-07 Clear One Box and 0 Cards Show: Silent Failure, Skipping Row Labels and 990 Unreduced Rows 2026-11-07 120 word slots, 15 whole sentences and 1 hard-coded opening: auditing the lorem-ipsum word list and measuring repeated fragments 2026-11-05 SVG preview sizes: the box equals the container, and r=1 measures 2 pixels once viewBox is removed 2026-11-05 Empty Fields Survive the Text Column Split: a,,b Stays, an Index Out of Range Fills Blank and 0 or abc Returns Every Column 2026-11-05