packages/base/src/inline-text-builder.js
import { get } from './util';
// eslint-disable-next-line import/no-unassigned-import
import './typedefs';
/**
* Helps to build text from words.
*/
class InlineTextBuilder {
/**
* Creates an instance of InlineTextBuilder.
*
* If `maxLineLength` is not provided then it is either `options.wordwrap` or unlimited.
*
* @param { Options } options HtmlToText options.
* @param { number } [ maxLineLength ] This builder will try to wrap text to fit this line length.
*/
constructor (options, maxLineLength = undefined) {
/** @type { string[][] } */
this.lines = [];
/** @type { string[] } */
this.nextLineWords = [];
this.maxLineLength = maxLineLength || options.wordwrap || Number.MAX_VALUE;
this.nextLineAvailableChars = this.maxLineLength;
this.wrapCharacters = get(options, ['longWordSplit', 'wrapCharacters']) || [];
this.forceWrapOnLimit = get(options, ['longWordSplit', 'forceWrapOnLimit']) || false;
this.stashedSpace = false;
this.wordBreakOpportunity = false;
}
/**
* Add a new word.
*
* @param { string } word A word to add.
* @param { boolean } [noWrap] Don't wrap text even if the line is too long.
*/
pushWord (word, noWrap = false) {
if (this.nextLineAvailableChars <= 0 && !noWrap) {
this.startNewLine();
}
const isLineStart = this.nextLineWords.length === 0;
const cost = word.length + (isLineStart ? 0 : 1);
if ((cost <= this.nextLineAvailableChars) || noWrap) { // Fits into available budget
this.nextLineWords.push(word);
this.nextLineAvailableChars -= cost;
} else { // Does not fit - try to split the word
// The word is moved to a new line - prefer to wrap between words.
const [first, ...rest] = this.splitLongWord(word);
if (!isLineStart) { this.startNewLine(); }
this.nextLineWords.push(first);
this.nextLineAvailableChars -= first.length;
for (const part of rest) {
this.startNewLine();
this.nextLineWords.push(part);
this.nextLineAvailableChars -= part.length;
}
}
}
/**
* Pop a word from the currently built line.
* This doesn't affect completed lines.
*
* @returns { string }
*/
popWord () {
const lastWord = this.nextLineWords.pop();
if (lastWord !== undefined) {
const isLineStart = this.nextLineWords.length === 0;
const cost = lastWord.length + (isLineStart ? 0 : 1);
this.nextLineAvailableChars += cost;
}
return lastWord;
}
/**
* Concat a word to the last word already in the builder.
* Adds a new word in case there are no words yet in the last line.
*
* @param { string } word A word to be concatenated.
* @param { boolean } [noWrap] Don't wrap text even if the line is too long.
*/
concatWord (word, noWrap = false) {
if (this.wordBreakOpportunity && word.length > this.nextLineAvailableChars) {
this.pushWord(word, noWrap);
this.wordBreakOpportunity = false;
} else {
const lastWord = this.popWord();
this.pushWord((lastWord) ? lastWord.concat(word) : word, noWrap);
}
}
/**
* Add current line (and more empty lines if provided argument > 1) to the list of complete lines and start a new one.
*
* @param { number } n Number of line breaks that will be added to the resulting string.
*/
startNewLine (n = 1) {
this.lines.push(this.nextLineWords);
if (n > 1) {
this.lines.push(...Array.from({ length: n - 1 }, () => []));
}
this.nextLineWords = [];
this.nextLineAvailableChars = this.maxLineLength;
}
/**
* No words in this builder.
*
* @returns { boolean }
*/
isEmpty () {
return this.lines.length === 0
&& this.nextLineWords.length === 0;
}
clear () {
this.lines.length = 0;
this.nextLineWords.length = 0;
this.nextLineAvailableChars = this.maxLineLength;
}
/**
* Join all lines of words inside the InlineTextBuilder into a complete string.
*
* @returns { string }
*/
toString () {
return [...this.lines, this.nextLineWords]
.map(words => words.join(' '))
.join('\n');
}
/**
* Split a long word up to fit within the word wrap limit.
* Use either a character to split looking back from the word wrap limit,
* or truncate to the word wrap limit.
*
* @param { string } word Input word.
* @returns { string[] } Parts of the word.
*/
splitLongWord (word) {
const parts = [];
let idx = 0;
while (word.length > this.maxLineLength) {
const firstLine = word.substring(0, this.maxLineLength);
const remainingChars = word.substring(this.maxLineLength);
const splitIndex = firstLine.lastIndexOf(this.wrapCharacters[idx]);
if (splitIndex > -1) { // Found a character to split on
word = firstLine.substring(splitIndex + 1) + remainingChars;
parts.push(firstLine.substring(0, splitIndex + 1));
} else { // Not found a character to split on
idx++;
if (idx < this.wrapCharacters.length) { // There is next character to try
word = firstLine + remainingChars;
} else { // No more characters to try
if (this.forceWrapOnLimit) {
parts.push(firstLine);
word = remainingChars;
if (word.length > this.maxLineLength) {
continue;
}
} else {
word = firstLine + remainingChars;
}
break;
}
}
}
parts.push(word); // Add remaining part to array
return parts;
}
}
export { InlineTextBuilder };