index.js
1 'use strict'; 2 const stringWidth = require('string-width'); 3 const stripAnsi = require('strip-ansi'); 4 const ansiStyles = require('ansi-styles'); 5 6 const ESCAPES = new Set([ 7 '\u001B', 8 '\u009B' 9 ]); 10 11 const END_CODE = 39; 12 13 const 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 17 const 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 21 const wrapWord = (rows, word, columns) => { 22 const characters = [...word]; 23 24 let isInsideEscape = 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 isInsideEscape = true; 39 } else if (isInsideEscape && character === 'm') { 40 isInsideEscape = false; 41 continue; 42 } 43 44 if (isInsideEscape) { 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 64 const 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 in either 'hard' or 'soft' wrap mode 84 // 85 // 'hard' will never allow a string to take up more than columns characters 86 // 87 // 'soft' allows long words to expand past the column length 88 const exec = (string, columns, options = {}) => { 89 if (options.trim !== false && string.trim() === '') { 90 return ''; 91 } 92 93 let pre = ''; 94 let ret = ''; 95 let escapeCode; 96 97 const lengths = wordLengths(string); 98 let rows = ['']; 99 100 for (const [index, word] of string.split(' ').entries()) { 101 if (options.trim !== false) { 102 rows[rows.length - 1] = rows[rows.length - 1].trimLeft(); 103 } 104 105 let rowLength = stringWidth(rows[rows.length - 1]); 106 107 if (index !== 0) { 108 if (rowLength >= columns && (options.wordWrap === false || options.trim === false)) { 109 // If we start with a new word but the current row length equals the length of the columns, add a new row 110 rows.push(''); 111 rowLength = 0; 112 } 113 114 if (rowLength > 0 || options.trim === false) { 115 rows[rows.length - 1] += ' '; 116 rowLength++; 117 } 118 } 119 120 // In 'hard' wrap mode, the length of a line is never allowed to extend past 'columns' 121 if (options.hard && lengths[index] > columns) { 122 const remainingColumns = (columns - rowLength); 123 const breaksStartingThisLine = 1 + Math.floor((lengths[index] - remainingColumns - 1) / columns); 124 const breaksStartingNextLine = Math.floor((lengths[index] - 1) / columns); 125 if (breaksStartingNextLine < breaksStartingThisLine) { 126 rows.push(''); 127 } 128 129 wrapWord(rows, word, columns); 130 continue; 131 } 132 133 if (rowLength + lengths[index] > columns && rowLength > 0 && lengths[index] > 0) { 134 if (options.wordWrap === false && rowLength < columns) { 135 wrapWord(rows, word, columns); 136 continue; 137 } 138 139 rows.push(''); 140 } 141 142 if (rowLength + lengths[index] > columns && options.wordWrap === false) { 143 wrapWord(rows, word, columns); 144 continue; 145 } 146 147 rows[rows.length - 1] += word; 148 } 149 150 if (options.trim !== false) { 151 rows = rows.map(stringVisibleTrimSpacesRight); 152 } 153 154 pre = rows.join('\n'); 155 156 for (const [index, character] of [...pre].entries()) { 157 ret += character; 158 159 if (ESCAPES.has(character)) { 160 const code = parseFloat(/\d[^m]*/.exec(pre.slice(index, index + 4))); 161 escapeCode = code === END_CODE ? null : code; 162 } 163 164 const code = ansiStyles.codes.get(Number(escapeCode)); 165 166 if (escapeCode && code) { 167 if (pre[index + 1] === '\n') { 168 ret += wrapAnsi(code); 169 } else if (character === '\n') { 170 ret += wrapAnsi(escapeCode); 171 } 172 } 173 } 174 175 return ret; 176 }; 177 178 // For each newline, invoke the method separately 179 module.exports = (string, columns, options) => { 180 return String(string) 181 .normalize() 182 .replace(/\r\n/g, '\n') 183 .split('\n') 184 .map(line => exec(line, columns, options)) 185 .join('\n'); 186 };