|
|
@@ -95,6 +95,9 @@ const INERT_CONTROL = /[\u0000-\u0007\u000b-\u001a\u001c-\u001f\u007f]/g
|
|
|
*/
|
|
|
const NEEDS_REPLAY = /\r|\u0008|\u001b\[[\u0030-\u003f]*[\u0020-\u002f]*K/
|
|
|
|
|
|
+/** SGR sequences alone, for folding state through a line that needs no replay. */
|
|
|
+const SGR_SEQUENCE = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*m/g
|
|
|
+
|
|
|
/** Terminal tab stop width; a tab advances to the next multiple of this. */
|
|
|
const TAB_WIDTH = 8
|
|
|
|
|
|
@@ -107,12 +110,18 @@ const ZERO_WIDTH = /^[\p{Mn}\p{Me}\p{Cf}\u200b-\u200f\u2060]$/u
|
|
|
|
|
|
/**
|
|
|
* Characters a terminal advances two columns for: CJK scripts, fullwidth forms,
|
|
|
- * CJK punctuation, and the emoji/symbol blocks a command's output realistically
|
|
|
- * carries.
|
|
|
+ * CJK punctuation, and characters with emoji presentation. Text-presentation
|
|
|
+ * symbols (`\u2713`, `\u26a0` and the rest of U+2600-U+27BF) are ONE column and
|
|
|
+ * must stay out of this set.
|
|
|
*/
|
|
|
const WIDE_CHAR = new RegExp(
|
|
|
'\\p{Script=Han}|\\p{Script=Hiragana}|\\p{Script=Katakana}|\\p{Script=Hangul}'
|
|
|
- + '|[\\u{1f300}-\\u{1faff}\\u{2600}-\\u{27bf}\\uff01-\\uff60\\u3000-\\u303e]',
|
|
|
+ // Emoji presentation only: the U+2600-U+27BF symbol block is mostly SINGLE
|
|
|
+ // width — `\u2713` (the check every progress line writes, this fixture
|
|
|
+ // included) advances one column, verified against a real terminal, so taking
|
|
|
+ // the whole block as wide misaligned exactly the output this card exists for.
|
|
|
+ + '|\\p{Emoji_Presentation}'
|
|
|
+ + '|[\\uff01-\\uff60\\u3000-\\u303e]',
|
|
|
'u',
|
|
|
)
|
|
|
|
|
|
@@ -129,6 +138,87 @@ function isWide(char: string): boolean {
|
|
|
return WIDE_CHAR.test(char)
|
|
|
}
|
|
|
|
|
|
+/**
|
|
|
+ * A cell's graphic state, normalized. Held as fields rather than as the raw
|
|
|
+ * sequence history because a terminal tracks CURRENT state, not a transcript:
|
|
|
+ * accumulating sequences made each state boundary re-emit the whole chain, so
|
|
|
+ * output that switches color without a full reset emitted O(n^2) characters
|
|
|
+ * (3200 such cells produced 25 MB and eventually a `RangeError`). It also makes
|
|
|
+ * the attribute closers every chalk-based tool writes — `39`, `49`, `22`, `23`,
|
|
|
+ * `24`, `27`, `29` — actually close their attribute instead of appending to it.
|
|
|
+ */
|
|
|
+interface SgrState {
|
|
|
+ fg: string
|
|
|
+ bg: string
|
|
|
+ /** Attribute parameters in force, e.g. `1` (bold) or `4` (underline). */
|
|
|
+ attrs: readonly string[]
|
|
|
+}
|
|
|
+
|
|
|
+/** The default state: no color, no attributes. */
|
|
|
+const SGR_NONE: SgrState = { fg: '', bg: '', attrs: [] }
|
|
|
+
|
|
|
+/** Attribute closers, mapped to the opener parameters each one turns off. */
|
|
|
+const ATTR_CLOSERS: Record<string, readonly string[]> = {
|
|
|
+ 22: ['1', '2'], 23: ['3'], 24: ['4'], 25: ['5', '6'], 27: ['7'], 28: ['8'], 29: ['9'],
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Fold one SGR sequence's parameters into the state it produces.
|
|
|
+ * @param state - state in force before the sequence.
|
|
|
+ * @param params - the sequence's raw parameter string (`31`, `1;4`, `38;5;208`).
|
|
|
+ * @returns the state the sequence leaves in force.
|
|
|
+ */
|
|
|
+function foldSgr(state: SgrState, params: string): SgrState {
|
|
|
+ const codes = params === '' ? ['0'] : params.split(';')
|
|
|
+ let next = state
|
|
|
+ for (let index = 0; index < codes.length; index++) {
|
|
|
+ const code = String(codes[index])
|
|
|
+ if (code === '' || code === '0') { next = SGR_NONE; continue }
|
|
|
+ // Extended color: `38;5;N` / `38;2;R;G;B` and the `48` background pair
|
|
|
+ // consume their own arguments, so they are taken whole.
|
|
|
+ if (code === '38' || code === '48') {
|
|
|
+ const kind = codes[index + 1] ?? ''
|
|
|
+ const span = kind === '2' ? 4 : kind === '5' ? 2 : 0
|
|
|
+ const value = codes.slice(index, index + span + 1).join(';')
|
|
|
+ next = code === '38' ? { ...next, fg: value } : { ...next, bg: value }
|
|
|
+ index += span
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const closes = ATTR_CLOSERS[code]
|
|
|
+ if (closes !== undefined) {
|
|
|
+ next = { ...next, attrs: next.attrs.filter(attr => !closes.includes(attr)) }
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ const numeric = Number(code)
|
|
|
+ if (code === '39') { next = { ...next, fg: '' }; continue }
|
|
|
+ if (code === '49') { next = { ...next, bg: '' }; continue }
|
|
|
+ if ((numeric >= 30 && numeric <= 37) || (numeric >= 90 && numeric <= 97)) { next = { ...next, fg: code }; continue }
|
|
|
+ if ((numeric >= 40 && numeric <= 47) || (numeric >= 100 && numeric <= 107)) { next = { ...next, bg: code }; continue }
|
|
|
+ if (!next.attrs.includes(code)) next = { ...next, attrs: [...next.attrs, code] }
|
|
|
+ }
|
|
|
+ return next
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Render a state as the one canonical sequence that establishes it from the
|
|
|
+ * default, so a boundary emits a bounded string no matter how the state was
|
|
|
+ * reached.
|
|
|
+ * @param state - the state to open.
|
|
|
+ * @returns the SGR sequence, or the empty string for the default state.
|
|
|
+ */
|
|
|
+function openSgr(state: SgrState): string {
|
|
|
+ const codes = [...state.attrs]
|
|
|
+ if (state.fg !== '') codes.push(state.fg)
|
|
|
+ if (state.bg !== '') codes.push(state.bg)
|
|
|
+ return codes.length === 0 ? '' : `\u001b[${codes.join(';')}m`
|
|
|
+}
|
|
|
+
|
|
|
+/** Whether two states are the same, so a boundary is only emitted on a change. */
|
|
|
+function sameSgr(a: SgrState, b: SgrState): boolean {
|
|
|
+ return a.fg === b.fg && a.bg === b.bg && a.attrs.length === b.attrs.length
|
|
|
+ && a.attrs.every((attr, index) => attr === b.attrs[index])
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Replay one line's cursor movements the way a terminal paints it, into a
|
|
|
* column buffer. Carriage return and backspace only MOVE the cursor — neither
|
|
|
@@ -149,19 +239,29 @@ function isWide(char: string): boolean {
|
|
|
* @returns the line as the terminal would have it after every movement, plus the
|
|
|
* SGR state at its end for the next line to enter with.
|
|
|
*/
|
|
|
-function replayLine(line: string, entrySgr: string): { text: string; sgr: string } {
|
|
|
+function replayLine(line: string, entrySgr: SgrState): { text: string; sgr: SgrState } {
|
|
|
// Same shape anser splits on, so a sequence is one unit here as well.
|
|
|
const csi = /\u001b\[([\u0030-\u003f]*)[\u0020-\u002f]*([\u0040-\u007e])/g
|
|
|
- /** Per column: the SGR state in force when it was written, and its character. */
|
|
|
- const columns: ({ sgr: string; char: string; spacer?: boolean } | undefined)[] = []
|
|
|
+ /** Per column: the state in force when it was written, and its character. */
|
|
|
+ const columns: (Cell | undefined)[] = []
|
|
|
let cursor = 0
|
|
|
- // SGR state accumulates as the line is scanned, exactly as a terminal tracks
|
|
|
- // it: each cell is stamped with whatever was in force at the moment of the
|
|
|
- // write, so a later redraw cannot restyle the cells it does not reach. It
|
|
|
- // enters carrying the previous line's state, since a newline does not reset it.
|
|
|
+ // State is tracked exactly as a terminal tracks it: each cell is stamped with
|
|
|
+ // whatever was in force at the moment of the write, so a later redraw cannot
|
|
|
+ // restyle the cells it does not reach. It enters carrying the previous line's
|
|
|
+ // state, since a newline does not reset it.
|
|
|
let sgr = entrySgr
|
|
|
let at = 0
|
|
|
|
|
|
+ /** Clear a cell and, for a wide pair, its partner: a terminal erases both. */
|
|
|
+ const clear = (index: number, fill: string): void => {
|
|
|
+ const cell = columns[index]
|
|
|
+ if (cell?.spacer === true && index > 0) columns[index - 1] = { sgr, char: fill }
|
|
|
+ else if (cell !== undefined && isWide(cell.char) && columns[index + 1]?.spacer === true) {
|
|
|
+ columns[index + 1] = { sgr, char: fill }
|
|
|
+ }
|
|
|
+ columns[index] = { sgr, char: fill }
|
|
|
+ }
|
|
|
+
|
|
|
const consume = (chunk: string): void => {
|
|
|
for (const char of chunk) {
|
|
|
if (char === '\r') { cursor = 0; continue }
|
|
|
@@ -176,13 +276,16 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
|
|
}
|
|
|
if (ZERO_WIDTH.test(char)) {
|
|
|
// No column of its own: it attaches to the cell already written, so a
|
|
|
- // redraw that covers that cell covers the mark with it.
|
|
|
- // With no cell to attach to (line start, or straight after a redraw to
|
|
|
- // column 0) a terminal shows nothing rather than a lone accent.
|
|
|
+ // redraw that covers that cell covers the mark with it. With no cell to
|
|
|
+ // attach to (line start, or straight after a redraw to column 0) a
|
|
|
+ // terminal shows nothing rather than a lone accent.
|
|
|
const base = cursor > 0 ? columns[cursor - 1] : undefined
|
|
|
if (base !== undefined) columns[cursor - 1] = { sgr: base.sgr, char: base.char + char }
|
|
|
continue
|
|
|
}
|
|
|
+ // Writing over either half of a wide pair blanks the other half, since a
|
|
|
+ // terminal cannot leave one cell of a two-cell glyph standing.
|
|
|
+ clear(cursor, ' ')
|
|
|
columns[cursor] = { sgr, char }
|
|
|
cursor++
|
|
|
// A wide character occupies two columns; the trailing one is a spacer,
|
|
|
@@ -205,31 +308,31 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
|
|
// standing, which is text the terminal never showed. `1` blanks from the
|
|
|
// line start THROUGH the cursor column (inclusive, per the CSI spec)
|
|
|
// rather than dropping those cells, since the cursor does not move and a
|
|
|
- // later write can still land past them.
|
|
|
- // Only the FIRST parameter selects the mode; a terminal ignores the rest
|
|
|
- // (`1;2K` erases exactly as `1K` does — verified against a real terminal).
|
|
|
+ // later write can still land past them. Only the FIRST parameter selects
|
|
|
+ // the mode; a terminal ignores the rest (`1;2K` erases exactly as `1K`).
|
|
|
const mode = String(params.split(';')[0])
|
|
|
- if (mode === '1') for (let index = 0; index <= cursor; index++) columns[index] = { sgr, char: ' ' }
|
|
|
+ if (mode === '1') for (let index = 0; index <= cursor; index++) clear(index, ' ')
|
|
|
else columns.length = mode === '2' ? 0 : cursor
|
|
|
continue
|
|
|
}
|
|
|
// Only SGR carries graphic state; every other final byte is a cursor or
|
|
|
- // erase action that must not be accumulated into a cell's style.
|
|
|
+ // erase action that must not affect a cell's style.
|
|
|
if (final !== 'm') continue
|
|
|
- sgr = /^0?$/.test(params) ? '' : sgr + match[0]
|
|
|
+ sgr = foldSgr(sgr, params)
|
|
|
}
|
|
|
consume(line.slice(at))
|
|
|
|
|
|
- // Re-emit the columns, opening a run only where its SGR state changes, so
|
|
|
- // anser sees the same styling a terminal shows. A `\x1b[2K` can leave holes
|
|
|
- // before the cursor, which a terminal paints as blanks.
|
|
|
+ // Re-emit the columns, opening a run only where its state changes, so anser
|
|
|
+ // sees the same styling a terminal shows. Each boundary emits ONE canonical
|
|
|
+ // sequence for the state it opens, which is what keeps the output linear in
|
|
|
+ // the number of cells however the state was reached.
|
|
|
let out = ''
|
|
|
let active = entrySgr
|
|
|
for (let index = 0; index < columns.length; index++) {
|
|
|
- const column = columns[index] ?? { sgr: '', char: ' ' }
|
|
|
- if (column.sgr !== active) {
|
|
|
- if (active !== '') out += '\u001b[0m'
|
|
|
- out += column.sgr
|
|
|
+ const column = columns[index] ?? { sgr: SGR_NONE, char: ' ' }
|
|
|
+ if (!sameSgr(column.sgr, active)) {
|
|
|
+ if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
|
|
|
+ out += openSgr(column.sgr)
|
|
|
active = column.sgr
|
|
|
}
|
|
|
// A spacer still holds its column. While its lead cell survives, the wide
|
|
|
@@ -243,13 +346,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
|
|
// sequence after the final write (the `\x1b[0m` closing a colored line) changes
|
|
|
// no cell yet still ends the run, and it has to reach both the DOM and the
|
|
|
// next line. Without this a line ending in a reset leaked its color onward.
|
|
|
- if (active !== sgr) {
|
|
|
- if (active !== '') out += '\u001b[0m'
|
|
|
- out += sgr
|
|
|
+ if (!sameSgr(active, sgr)) {
|
|
|
+ if (!sameSgr(active, SGR_NONE)) out += '\u001b[0m'
|
|
|
+ out += openSgr(sgr)
|
|
|
}
|
|
|
return { text: out, sgr }
|
|
|
}
|
|
|
|
|
|
+/** One replayed column: the state it was written with, and its character. */
|
|
|
+interface Cell {
|
|
|
+ sgr: SgrState
|
|
|
+ char: string
|
|
|
+ /** The trailing half of a wide character's two-column pair. */
|
|
|
+ spacer?: boolean
|
|
|
+}
|
|
|
+
|
|
|
/**
|
|
|
* Replay every line's cursor movements. A `\r` that only terminates a CRLF line
|
|
|
* is dropped first, so those lines keep their text instead of being redrawn onto
|
|
|
@@ -260,18 +371,21 @@ function replayLine(line: string, entrySgr: string): { text: string; sgr: string
|
|
|
*/
|
|
|
function applyCursorMovements(text: string): string {
|
|
|
const replayed: string[] = []
|
|
|
- let sgr = ''
|
|
|
+ let sgr = SGR_NONE
|
|
|
for (const raw of text.split('\n')) {
|
|
|
const line = raw.replace(/\r+$/, '')
|
|
|
- // A line with no cursor movement or erase needs no replay — its tabs stay
|
|
|
- // literal for `white-space: pre` to lay out — but its own SGR still has to
|
|
|
- // be tracked so a later line that DOES replay enters with the right state.
|
|
|
- // Tabs only need column arithmetic where a redraw can land on them, which is
|
|
|
- // exactly the replayed case. An erase counts: `\x1b[1K` blanks columns even
|
|
|
- // with no `\r` beside it.
|
|
|
- const result = replayLine(line, sgr)
|
|
|
- replayed.push(NEEDS_REPLAY.test(line) ? result.text : line)
|
|
|
- sgr = result.sgr
|
|
|
+ if (NEEDS_REPLAY.test(line)) {
|
|
|
+ const result = replayLine(line, sgr)
|
|
|
+ replayed.push(result.text)
|
|
|
+ sgr = result.sgr
|
|
|
+ continue
|
|
|
+ }
|
|
|
+ // No cursor movement: the line needs no column buffer, and painting one
|
|
|
+ // would allocate a cell per character of output this card never redraws —
|
|
|
+ // an `ls -R` or a 5k-line log. Only its own SGR has to be folded, so a later
|
|
|
+ // line that DOES replay enters with the right state.
|
|
|
+ replayed.push(line)
|
|
|
+ for (const match of line.matchAll(SGR_SEQUENCE)) sgr = foldSgr(sgr, String(match[1]))
|
|
|
}
|
|
|
return replayed.join('\n')
|
|
|
}
|