wrap-ansi
Version:
Wordwrap a string with ANSI escape codes
469 lines (381 loc) • 12.9 kB
JavaScript
import stringWidth from 'string-width';
import stripAnsi from 'strip-ansi';
import ansiStyles from 'ansi-styles';
const ANSI_ESCAPE = '\u001B';
const ANSI_ESCAPE_CSI = '\u009B';
const ESCAPES = new Set([
ANSI_ESCAPE,
ANSI_ESCAPE_CSI,
]);
const ANSI_ESCAPE_BELL = '\u0007';
const ANSI_CSI = '[';
const ANSI_OSC = ']';
const ANSI_SGR_TERMINATOR = 'm';
const ANSI_SGR_RESET = 0;
const ANSI_SGR_RESET_FOREGROUND = 39;
const ANSI_SGR_RESET_BACKGROUND = 49;
const ANSI_SGR_RESET_UNDERLINE_COLOR = 59;
const ANSI_SGR_FOREGROUND_EXTENDED = 38;
const ANSI_SGR_BACKGROUND_EXTENDED = 48;
const ANSI_SGR_UNDERLINE_COLOR_EXTENDED = 58;
const ANSI_SGR_COLOR_MODE_256 = 5;
const ANSI_SGR_COLOR_MODE_RGB = 2;
const ANSI_ESCAPE_LINK = `${ANSI_OSC}8;;`;
const ANSI_ESCAPE_REGEX = new RegExp(`^\\u001B(?:\\${ANSI_CSI}(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}|${ANSI_ESCAPE_LINK}(?<uri>[^\\u0007\\u001B]*)(?:\\u0007|\\u001B\\\\))`);
const ANSI_ESCAPE_CSI_REGEX = new RegExp(`^\\u009B(?<sgr>[0-9;]*)${ANSI_SGR_TERMINATOR}`);
const ANSI_SGR_MODIFIER_CLOSE_CODES = new Set(ansiStyles.codes.values());
ANSI_SGR_MODIFIER_CLOSE_CODES.delete(ANSI_SGR_RESET);
const segmenter = new Intl.Segmenter();
const getGraphemes = string => Array.from(segmenter.segment(string), ({segment}) => segment);
const TAB_SIZE = 8;
const wrapAnsiCode = code => `${ANSI_ESCAPE}${ANSI_CSI}${code}${ANSI_SGR_TERMINATOR}`;
const wrapAnsiHyperlink = url => `${ANSI_ESCAPE}${ANSI_ESCAPE_LINK}${url}${ANSI_ESCAPE_BELL}`;
const getSgrTokens = sgrParameters => {
const codes = sgrParameters.split(';').map(sgrParameter => sgrParameter === '' ? ANSI_SGR_RESET : Number.parseInt(sgrParameter, 10));
const sgrTokens = [];
for (let index = 0; index < codes.length; index++) {
const code = codes[index];
if (!Number.isFinite(code)) {
continue;
}
if (
(
code === ANSI_SGR_FOREGROUND_EXTENDED
|| code === ANSI_SGR_BACKGROUND_EXTENDED
|| code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED
)
) {
if (index + 1 >= codes.length) {
break;
}
const mode = codes[index + 1];
if (mode === ANSI_SGR_COLOR_MODE_256 && Number.isFinite(codes[index + 2])) {
sgrTokens.push([code, mode, codes[index + 2]]);
index += 2;
continue;
}
const red = codes[index + 2];
const green = codes[index + 3];
const blue = codes[index + 4];
if (
mode === ANSI_SGR_COLOR_MODE_RGB
&& Number.isFinite(red)
&& Number.isFinite(green)
&& Number.isFinite(blue)
) {
sgrTokens.push([code, mode, red, green, blue]);
index += 4;
continue;
}
break;
}
sgrTokens.push([code]);
}
return sgrTokens;
};
const removeActiveStyle = (activeStyles, family) => {
const activeStyleIndex = activeStyles.findIndex(activeStyle => activeStyle.family === family);
if (activeStyleIndex !== -1) {
activeStyles.splice(activeStyleIndex, 1);
}
};
const upsertActiveStyle = (activeStyles, nextActiveStyle) => {
removeActiveStyle(activeStyles, nextActiveStyle.family);
activeStyles.push(nextActiveStyle);
};
const removeModifierStylesByClose = (activeStyles, closeCode) => {
for (let index = activeStyles.length - 1; index >= 0; index--) {
const activeStyle = activeStyles[index];
if (activeStyle.family.startsWith('modifier-') && activeStyle.close === closeCode) {
activeStyles.splice(index, 1);
}
}
};
const getColorStyle = (code, sgrToken) => {
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97) || (code === ANSI_SGR_FOREGROUND_EXTENDED && sgrToken.length > 1)) {
return {
family: 'foreground',
open: sgrToken.join(';'),
close: ANSI_SGR_RESET_FOREGROUND,
};
}
if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107) || (code === ANSI_SGR_BACKGROUND_EXTENDED && sgrToken.length > 1)) {
return {
family: 'background',
open: sgrToken.join(';'),
close: ANSI_SGR_RESET_BACKGROUND,
};
}
if (code === ANSI_SGR_UNDERLINE_COLOR_EXTENDED && sgrToken.length > 1) {
return {
family: 'underlineColor',
open: sgrToken.join(';'),
close: ANSI_SGR_RESET_UNDERLINE_COLOR,
};
}
};
const applySgrResetCode = (code, activeStyles) => {
if (code === ANSI_SGR_RESET) {
activeStyles.length = 0;
return true;
}
if (code === ANSI_SGR_RESET_FOREGROUND) {
removeActiveStyle(activeStyles, 'foreground');
return true;
}
if (code === ANSI_SGR_RESET_BACKGROUND) {
removeActiveStyle(activeStyles, 'background');
return true;
}
if (code === ANSI_SGR_RESET_UNDERLINE_COLOR) {
removeActiveStyle(activeStyles, 'underlineColor');
return true;
}
if (ANSI_SGR_MODIFIER_CLOSE_CODES.has(code)) {
removeModifierStylesByClose(activeStyles, code);
return true;
}
return false;
};
const applySgrToken = (sgrToken, activeStyles) => {
const [code] = sgrToken;
if (applySgrResetCode(code, activeStyles)) {
return;
}
const colorStyle = getColorStyle(code, sgrToken);
if (colorStyle) {
upsertActiveStyle(activeStyles, colorStyle);
return;
}
const close = ansiStyles.codes.get(code);
if (close !== undefined && close !== ANSI_SGR_RESET) {
upsertActiveStyle(activeStyles, {
family: `modifier-${code}`,
open: sgrToken.join(';'),
close,
});
}
};
const applySgrParameters = (sgrParameters, activeStyles) => {
for (const sgrToken of getSgrTokens(sgrParameters)) {
applySgrToken(sgrToken, activeStyles);
}
};
const applySgrResets = (sgrParameters, activeStyles) => {
for (const sgrToken of getSgrTokens(sgrParameters)) {
const [code] = sgrToken;
applySgrResetCode(code, activeStyles);
}
};
const applyLeadingSgrResets = (string, activeStyles) => {
let remainder = string;
while (remainder.length > 0) {
if (remainder.startsWith(ANSI_ESCAPE) && remainder[1] !== '\\') {
const match = ANSI_ESCAPE_REGEX.exec(remainder);
if (!match) {
break;
}
if (match.groups.sgr !== undefined) {
applySgrResets(match.groups.sgr, activeStyles);
}
remainder = remainder.slice(match[0].length);
continue;
}
if (remainder.startsWith(ANSI_ESCAPE_CSI)) {
const match = ANSI_ESCAPE_CSI_REGEX.exec(remainder);
if (!match || match.groups.sgr === undefined) {
break;
}
applySgrResets(match.groups.sgr, activeStyles);
remainder = remainder.slice(match[0].length);
continue;
}
break;
}
};
const getClosingSgrSequence = activeStyles => [...activeStyles].reverse().map(activeStyle => wrapAnsiCode(activeStyle.close)).join('');
const getOpeningSgrSequence = activeStyles => activeStyles.map(activeStyle => wrapAnsiCode(activeStyle.open)).join('');
// Calculate the length of words split on ' ', ignoring
// the extra characters added by ANSI escape codes
const wordLengths = string => string.split(' ').map(word => stringWidth(word));
// Wrap a long word across multiple rows
// ANSI escape codes do not count towards length
const wrapWord = (rows, word, columns) => {
const characters = getGraphemes(word);
let isInsideEscape = false;
let isInsideLinkEscape = false;
let visible = stringWidth(stripAnsi(rows.at(-1)));
for (const [index, character] of characters.entries()) {
const characterLength = stringWidth(character);
if (visible + characterLength <= columns) {
rows[rows.length - 1] += character;
} else {
rows.push(character);
visible = 0;
}
if (ESCAPES.has(character) && !(isInsideLinkEscape && character === ANSI_ESCAPE && characters[index + 1] === '\\')) {
isInsideEscape = true;
const ansiEscapeLinkCandidate = characters.slice(index + 1, index + 1 + ANSI_ESCAPE_LINK.length).join('');
isInsideLinkEscape = ansiEscapeLinkCandidate === ANSI_ESCAPE_LINK;
}
if (isInsideEscape) {
if (isInsideLinkEscape) {
if (
character === ANSI_ESCAPE_BELL
|| (character === '\\' && index > 0 && characters[index - 1] === ANSI_ESCAPE) // ST terminator (ESC \)
) {
isInsideEscape = false;
isInsideLinkEscape = false;
}
} else if (character === ANSI_SGR_TERMINATOR) {
isInsideEscape = false;
}
continue;
}
visible += characterLength;
if (visible === columns && index < characters.length - 1) {
rows.push('');
visible = 0;
}
}
// It's possible that the last row we copy over is only
// ANSI escape characters, handle this edge-case
if (!visible && rows.at(-1).length > 0 && rows.length > 1) {
rows[rows.length - 2] += rows.pop();
}
};
// Trims spaces from a string ignoring invisible sequences
const stringVisibleTrimSpacesRight = string => {
const words = string.split(' ');
let last = words.length;
while (last > 0) {
if (stringWidth(words[last - 1]) > 0) {
break;
}
last--;
}
if (last === words.length) {
return string;
}
return words.slice(0, last).join(' ') + words.slice(last).join('');
};
const expandTabs = line => {
if (!line.includes('\t')) {
return line;
}
const segments = line.split('\t');
let visible = 0;
let expandedLine = '';
for (const [index, segment] of segments.entries()) {
expandedLine += segment;
visible += stringWidth(segment);
if (index < segments.length - 1) {
const spaces = TAB_SIZE - (visible % TAB_SIZE);
expandedLine += ' '.repeat(spaces);
visible += spaces;
}
}
return expandedLine;
};
// The wrap-ansi module can be invoked in either 'hard' or 'soft' wrap mode.
//
// 'hard' will never allow a string to take up more than columns characters.
//
// 'soft' allows long words to expand past the column length.
const exec = (string, columns, options = {}) => {
if (options.trim !== false && string.trim() === '') {
return '';
}
let returnValue = '';
let escapeUrl;
const activeStyles = [];
const lengths = wordLengths(string);
let rows = [''];
for (const [index, word] of string.split(' ').entries()) {
if (options.trim !== false) {
rows[rows.length - 1] = rows.at(-1).trimStart();
}
let rowLength = stringWidth(rows.at(-1));
if (index !== 0) {
if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) {
// If we start with a new word but the current row length equals the length of the columns, add a new row
rows.push('');
rowLength = 0;
}
if (rowLength > 0 || options.trim === false) {
rows[rows.length - 1] += ' ';
rowLength++;
}
}
// In 'hard' wrap mode, the length of a line is never allowed to extend past 'columns'
if (options.hard && options.wordWrap !== false && lengths[index] > columns) {
const remainingColumns = columns - rowLength;
const breaksStartingThisLine = 1 + Math.floor((lengths[index] - remainingColumns - 1) / columns);
const breaksStartingNextLine = Math.floor((lengths[index] - 1) / columns);
if (breaksStartingNextLine < breaksStartingThisLine) {
rows.push('');
}
wrapWord(rows, word, columns);
continue;
}
if (rowLength + lengths[index] > columns && rowLength > 0 && lengths[index] > 0) {
if (options.wordWrap === false && rowLength < columns) {
wrapWord(rows, word, columns);
continue;
}
rows.push('');
}
if (rowLength + lengths[index] > columns && options.wordWrap === false) {
wrapWord(rows, word, columns);
continue;
}
rows[rows.length - 1] += word;
}
if (options.trim !== false) {
rows = rows.map(row => stringVisibleTrimSpacesRight(row));
}
const preString = rows.join('\n');
const pre = getGraphemes(preString);
// We need to keep a separate index as `String#slice()` works on Unicode code units, while `pre` is an array of grapheme clusters.
let preStringIndex = 0;
for (const [index, character] of pre.entries()) {
returnValue += character;
if (character === ANSI_ESCAPE && pre[index + 1] !== '\\') {
const {groups} = ANSI_ESCAPE_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
if (groups.sgr !== undefined) {
applySgrParameters(groups.sgr, activeStyles);
} else if (groups.uri !== undefined) {
escapeUrl = groups.uri.length === 0 ? undefined : groups.uri;
}
} else if (character === ANSI_ESCAPE_CSI) {
const {groups} = ANSI_ESCAPE_CSI_REGEX.exec(preString.slice(preStringIndex)) || {groups: {}};
if (groups.sgr !== undefined) {
applySgrParameters(groups.sgr, activeStyles);
}
}
if (pre[index + 1] === '\n') {
if (escapeUrl) {
returnValue += wrapAnsiHyperlink('');
}
returnValue += getClosingSgrSequence(activeStyles);
} else if (character === '\n') {
const openingStyles = [...activeStyles];
applyLeadingSgrResets(preString.slice(preStringIndex + 1), openingStyles);
returnValue += getOpeningSgrSequence(openingStyles);
if (escapeUrl) {
returnValue += wrapAnsiHyperlink(escapeUrl);
}
}
preStringIndex += character.length;
}
return returnValue;
};
// For each newline, invoke the method separately
export default function wrapAnsi(string, columns, options) {
return String(string)
.normalize()
.replaceAll('\r\n', '\n')
.split('\n')
.map(line => exec(expandTabs(line), columns, options))
.join('\n');
}