1'use strict'; 2const stringWidth = require('string-width'); 3const stripAnsi = require('strip-ansi'); 4const ansiStyles = require('ansi-styles'); 5 6const ESCAPES = new Set([ 7 '\u001B', 8 '\u009B' 9]); 10 11const END_CODE = 39; 12 13const wrapAnsi = code => `${ESCAPES.values().next().value}[${code}m`; 14 15// Calculate the length of words split on ' ', ignoring 16// the extra characters added by ansi escape codes 17const wordLengths = string => string.split(' ').map(character => stringWidth(character)); 18 19// Wrap a long word across multiple rows 20// Ansi escape codes do not count towards length 21const wrapWord = (rows, word, columns) => { 22 const characters = [...word]; 23 24 let insideEscape = false; 25 let visible = stringWidth(stripAnsi(rows[rows.length - 1])); 26 27 for (const [index, character] of characters.entries()) { 28 const characterLength = stringWidth(character); 29 30 if (visible + characterLength <= columns) { 31 rows[rows.length - 1] += character; 32 } else { 33 rows.push(character); 34 visible = 0; 35 } 36 37 if (ESCAPES.has(character)) { 38 insideEscape = true; 39 } else if (insideEscape && character === 'm') { 40 insideEscape = false; 41 continue; 42 } 43 44 if (insideEscape) { 45 continue; 46 } 47 48 visible += characterLength; 49 50 if (visible === columns && index < characters.length - 1) { 51 rows.push(''); 52 visible = 0; 53 } 54 } 55 56 // It's possible that the last row we copy over is only 57 // ansi escape characters, handle this edge-case 58 if (!visible && rows[rows.length - 1].length > 0 && rows.length > 1) { 59 rows[rows.length - 2] += rows.pop(); 60 } 61}; 62 63// Trims spaces from a string ignoring invisible sequences 64const stringVisibleTrimSpacesRight = str => { 65 const words = str.split(' '); 66 let last = words.length; 67 68 while (last > 0) { 69 if (stringWidth(words[last - 1]) > 0) { 70 break; 71 } 72 73 last--; 74 } 75 76 if (last === words.length) { 77 return str; 78 } 79 80 return words.slice(0, last).join(' ') + words.slice(last).join(''); 81}; 82 83// The wrap-ansi module can be invoked 84// in either 'hard' or 'soft' wrap mode 85// 86// 'hard' will never allow a string to take up more 87// than columns characters 88// 89// 'soft' allows long words to expand past the column length 90const exec = (string, columns, options = {}) => { 91 if (options.trim !== false && string.trim() === '') { 92 return ''; 93 } 94 95 let pre = ''; 96 let ret = ''; 97 let escapeCode; 98 99 const lengths = wordLengths(string); 100 let rows = ['']; 101 102 for (const [index, word] of string.split(' ').entries()) { 103 if (options.trim !== false) { 104 rows[rows.length - 1] = rows[rows.length - 1].trimLeft(); 105 } 106 107 let rowLength = stringWidth(rows[rows.length - 1]); 108 109 if (index !== 0) { 110 if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) { 111 // If we start with a new word but the current row length equals the length of the columns, add a new row 112 rows.push(''); 113 rowLength = 0; 114 } 115 116 if (rowLength > 0 || options.trim === false) { 117 rows[rows.length - 1] += ' '; 118 rowLength++; 119 } 120 } 121 122 // In 'hard' wrap mode, the length of a line is 123 // never allowed to extend past 'columns' 124 if (options.hard && lengths[index] > columns) { 125 const remainingColumns = (columns - rowLength); 126 const breaksStartingThisLine = 1 + Math.floor((lengths[index] - remainingColumns - 1) / columns); 127 const breaksStartingNextLine = Math.floor((lengths[index] - 1) / columns); 128 if (breaksStartingNextLine < breaksStartingThisLine) { 129 rows.push(''); 130 } 131 132 wrapWord(rows, word, columns); 133 continue; 134 } 135 136 if (rowLength + lengths[index] > columns && rowLength > 0 && lengths[index] > 0) { 137 if (options.wordWrap === false && rowLength < columns) { 138 wrapWord(rows, word, columns); 139 continue; 140 } 141 142 rows.push(''); 143 } 144 145 if (rowLength + lengths[index] > columns && options.wordWrap === false) { 146 wrapWord(rows, word, columns); 147 continue; 148 } 149 150 rows[rows.length - 1] += word; 151 } 152 153 if (options.trim !== false) { 154 rows = rows.map(stringVisibleTrimSpacesRight); 155 } 156 157 pre = rows.join('\n'); 158 159 for (const [index, character] of [...pre].entries()) { 160 ret += character; 161 162 if (ESCAPES.has(character)) { 163 const code = parseFloat(/\d[^m]*/.exec(pre.slice(index, index + 4))); 164 escapeCode = code === END_CODE ? null : code; 165 } 166 167 const code = ansiStyles.codes.get(Number(escapeCode)); 168 169 if (escapeCode && code) { 170 if (pre[index + 1] === '\n') { 171 ret += wrapAnsi(code); 172 } else if (character === '\n') { 173 ret += wrapAnsi(escapeCode); 174 } 175 } 176 } 177 178 return ret; 179}; 180 181// For each newline, invoke the method separately 182module.exports = (string, columns, options) => { 183 return String(string) 184 .normalize() 185 .split('\n') 186 .map(line => exec(line, columns, options)) 187 .join('\n'); 188}; 189