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")
|
||
}
|