290 lines
7.6 KiB
Plaintext
290 lines
7.6 KiB
Plaintext
|
const breakword = require("breakword")
|
|||
|
const stripansi = require("strip-ansi")
|
|||
|
const wcwidth = require("wcwidth")
|
|||
|
const flat = require("array.prototype.flat")
|
|||
|
if (!Array.prototype.flat) flat.shim()
|
|||
|
|
|||
|
const ANSIPattern = [
|
|||
|
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
|
|||
|
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"
|
|||
|
].join("|")
|
|||
|
const ANSIRegex = new RegExp(ANSIPattern, "g")
|
|||
|
|
|||
|
const defaults = () => {
|
|||
|
let obj = {}
|
|||
|
|
|||
|
obj.breakword = false
|
|||
|
obj.input = [] // input string split by whitespace
|
|||
|
obj.minWidth = 2 // fallback to if width set too narrow
|
|||
|
obj.paddingLeft = 0
|
|||
|
obj.paddingRight = 0
|
|||
|
obj.errorChar = "<22>"
|
|||
|
obj.returnFormat = "string" // or 'array'
|
|||
|
obj.skipPadding = false // set to true when padding set too wide for line length
|
|||
|
obj.splitAt = [" ", "\t"]
|
|||
|
obj.trim = true
|
|||
|
obj.width = 10
|
|||
|
|
|||
|
return obj
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
const calculateSpaceRemaining = function(lineLength, spacesUsed, config) {
|
|||
|
return Math.max(lineLength - spacesUsed - config.paddingLeft - config.paddingRight, 0)
|
|||
|
} // function to set starting line length
|
|||
|
|
|||
|
|
|||
|
const validateInput = (text, options) => {
|
|||
|
|
|||
|
// options validation
|
|||
|
let config = Object.assign({}, defaults(), options || {})
|
|||
|
|
|||
|
if (config.errorChar) {
|
|||
|
// only allow a single errorChar
|
|||
|
config.errorChar = config.errorChar.split("")[0]
|
|||
|
|
|||
|
// errorChar must not be wide character
|
|||
|
if (wcwidth(config.errorChar) > 1)
|
|||
|
throw new Error(`Error character cannot be a wide character (${config.errorChar})`)
|
|||
|
}
|
|||
|
|
|||
|
// make sure correct sign on padding
|
|||
|
config.paddingLeft = Math.abs(config.paddingLeft)
|
|||
|
config.paddingRight = Math.abs(config.paddingRight)
|
|||
|
|
|||
|
let lineLength = config.width
|
|||
|
- config.paddingLeft
|
|||
|
- config.paddingRight
|
|||
|
|
|||
|
if(lineLength < config.minWidth) {
|
|||
|
// skip padding if lineLength too narrow
|
|||
|
config.skipPadding = true
|
|||
|
lineLength = config.minWidth
|
|||
|
}
|
|||
|
|
|||
|
// to trim or not to trim...
|
|||
|
if(config.trim) {
|
|||
|
text = text.trim()
|
|||
|
}
|
|||
|
|
|||
|
return { text, config, lineLength }
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
const wrap = (input, options) => {
|
|||
|
|
|||
|
let { text, config, lineLength } = validateInput(input, options)
|
|||
|
|
|||
|
// array of characters split by whitespace and/or tabs
|
|||
|
let words = []
|
|||
|
|
|||
|
if(!config.breakword) {
|
|||
|
// break string into words
|
|||
|
if(config.splitAt.indexOf("\t")!==-1) {
|
|||
|
// split at both spaces and tabs
|
|||
|
words = text.split(/ |\t/i)
|
|||
|
} else{
|
|||
|
// split at whitespace
|
|||
|
words = text.split(" ")
|
|||
|
}
|
|||
|
} else {
|
|||
|
// do not break string into words
|
|||
|
words = [text]
|
|||
|
}
|
|||
|
|
|||
|
// remove empty array elements
|
|||
|
words = words.filter(val => {
|
|||
|
if (val.length > 0) {
|
|||
|
return true
|
|||
|
}
|
|||
|
})
|
|||
|
|
|||
|
// assume at least one line
|
|||
|
let lines = [
|
|||
|
[]
|
|||
|
]
|
|||
|
|
|||
|
let spaceRemaining, splitIndex, word
|
|||
|
let currentLine = 0 // index of current line in 'lines[]'
|
|||
|
let spacesUsed = 0 // spaces used so far on current line
|
|||
|
|
|||
|
while(words.length > 0) {
|
|||
|
spaceRemaining = calculateSpaceRemaining(lineLength, spacesUsed, config)
|
|||
|
word = words.shift()
|
|||
|
let wordLength = wcwidth(word)
|
|||
|
|
|||
|
switch(true) {
|
|||
|
|
|||
|
// too long for an empty line and is a single character
|
|||
|
case(lineLength < wordLength && [...word].length === 1):
|
|||
|
words.unshift(config.errorChar)
|
|||
|
break
|
|||
|
|
|||
|
// too long for an empty line, must be broken between 2 lines
|
|||
|
case(lineLength < wordLength):
|
|||
|
// break it, then re-insert its parts into words
|
|||
|
// so can loop back to re-handle each word
|
|||
|
splitIndex = breakword(word, lineLength)
|
|||
|
let splitWord = [...word]
|
|||
|
words.unshift(splitWord.slice(0, splitIndex + 1).join(""))
|
|||
|
words.splice(1, 0, splitWord.slice(splitIndex + 1).join("")) // +1 for substr fn
|
|||
|
break
|
|||
|
|
|||
|
// not enough space remaining in line, must be wrapped to next line
|
|||
|
case(spaceRemaining < wordLength):
|
|||
|
// add a new line to our array of lines
|
|||
|
lines.push([])
|
|||
|
// note carriage to new line in counter
|
|||
|
currentLine++
|
|||
|
// reset the spacesUsed to 0
|
|||
|
spacesUsed = 0
|
|||
|
/* falls through */
|
|||
|
|
|||
|
// fits on current line
|
|||
|
// eslint-disable-next-line
|
|||
|
default:
|
|||
|
// add word to line
|
|||
|
lines[currentLine].push(word)
|
|||
|
// reduce space remaining (add a space between words)
|
|||
|
spacesUsed += wordLength + 1
|
|||
|
}
|
|||
|
}
|
|||
|
|
|||
|
lines = lines.map( line => {
|
|||
|
|
|||
|
// restore spaces to line
|
|||
|
line = line.join(" ")
|
|||
|
|
|||
|
// add padding to ends of line
|
|||
|
if(!config.skipPadding) {
|
|||
|
line = Array(config.paddingLeft + 1).join(" ")
|
|||
|
+ line
|
|||
|
+ Array(config.paddingRight + 1).join(" ")
|
|||
|
}
|
|||
|
|
|||
|
return line
|
|||
|
})
|
|||
|
|
|||
|
return lines.join("\n")
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
const splitAnsiInput = (text) => {
|
|||
|
// get start and end positions for matches
|
|||
|
let matches = []
|
|||
|
let textArr = [...text]
|
|||
|
let textLength = textArr.length
|
|||
|
|
|||
|
/* eslint-disable */
|
|||
|
while((result = ANSIRegex.exec(text)) !== null) {
|
|||
|
matches.push({
|
|||
|
start: result.index,
|
|||
|
end: result.index + result[0].length,
|
|||
|
match: result[0],
|
|||
|
length: result[0].length
|
|||
|
})
|
|||
|
}
|
|||
|
/* eslint-enable */
|
|||
|
|
|||
|
|
|||
|
if (matches.length < 1) return [] // we have no ANSI escapes, we're done here
|
|||
|
|
|||
|
// add start and end positions for non matches
|
|||
|
matches = matches.reduce((prev, curr) => {
|
|||
|
// check if space exists between this and last match
|
|||
|
// get end of previous match
|
|||
|
let prevEnd = prev[prev.length -1]
|
|||
|
|
|||
|
if (prevEnd.end < curr.start) {
|
|||
|
// insert placeholder
|
|||
|
prev.push({
|
|||
|
start: prevEnd.end,
|
|||
|
end: curr.start,
|
|||
|
length: curr.start - prevEnd.end,
|
|||
|
expand: true
|
|||
|
}, curr)
|
|||
|
} else {
|
|||
|
prev.push(curr)
|
|||
|
}
|
|||
|
return prev
|
|||
|
}, [{start: 0, end: 0}])
|
|||
|
.splice(1) // removes starting accumulator object
|
|||
|
|
|||
|
|
|||
|
// add trailing match if necessary
|
|||
|
let lastMatchEnd = matches[matches.length - 1].end
|
|||
|
if (lastMatchEnd < textLength) {
|
|||
|
matches.push({
|
|||
|
start: lastMatchEnd,
|
|||
|
end: textLength,
|
|||
|
expand: true
|
|||
|
})
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
let savedArr = matches.map(match => {
|
|||
|
let value = text.substring(match.start, match.end)
|
|||
|
return (match.expand) ? [...value] : [value]
|
|||
|
}).flat(2)
|
|||
|
|
|||
|
return savedArr
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
const restoreANSI = (savedArr, processedArr) => {
|
|||
|
return processedArr.map((char) => {
|
|||
|
let result
|
|||
|
|
|||
|
if (char === "\n") {
|
|||
|
result = [char]
|
|||
|
} else {
|
|||
|
// add everything saved before character match
|
|||
|
let splicePoint = savedArr.findIndex(element => element === char ) + 1
|
|||
|
result = savedArr.splice(0, splicePoint)
|
|||
|
}
|
|||
|
|
|||
|
// add all following, consecutive closing tags in case linebreak inerted next
|
|||
|
const ANSIClosePattern = "^\\x1b\\[([0-9]+)*m"
|
|||
|
const ANSICloseRegex = new RegExp(ANSIClosePattern) // eslint-disable-line no-control-regex
|
|||
|
const closeCodes = ["0", "21", "22", "23", "24", "25", "27", "28", "29", "39", "49", "54", "55"]
|
|||
|
|
|||
|
let match
|
|||
|
while (savedArr.length && (match = savedArr[0].match(ANSICloseRegex))) {
|
|||
|
if (!closeCodes.includes(match[1])) break
|
|||
|
result.push(savedArr.shift())
|
|||
|
}
|
|||
|
|
|||
|
return result.join("")
|
|||
|
}).concat(savedArr)
|
|||
|
}
|
|||
|
|
|||
|
|
|||
|
module.exports = (input, options) => {
|
|||
|
|
|||
|
// process each existing line separately to respect existing line breaks
|
|||
|
const processedLines = input.toString().split("\n").map( string => {
|
|||
|
|
|||
|
// save input ANSI escape codes to be restored later
|
|||
|
const savedANSI = splitAnsiInput(string)
|
|||
|
|
|||
|
// strip ANSI
|
|||
|
string = stripansi(string)
|
|||
|
|
|||
|
// add newlines to string
|
|||
|
string = wrap(string, options)
|
|||
|
|
|||
|
// convert into array of characters
|
|||
|
let charArr = [...string]
|
|||
|
|
|||
|
// restore input ANSI escape codes
|
|||
|
charArr = (savedANSI.length > 0) ? restoreANSI(savedANSI, charArr) : charArr
|
|||
|
|
|||
|
// convert array of single characters into array of lines
|
|||
|
let outArr = charArr.join("").split("\n")
|
|||
|
|
|||
|
return outArr
|
|||
|
})
|
|||
|
|
|||
|
return processedLines.flat(2).join("\n")
|
|||
|
}
|