diff --git a/packages/text/src/TextMetrics.js b/packages/text/src/TextMetrics.js index c4f32e1..f4c6c0c 100644 --- a/packages/text/src/TextMetrics.js +++ b/packages/text/src/TextMetrics.js @@ -1,6 +1,11 @@ /** * The TextMetrics object represents the measurement of a block of text with a specified style. * + * ```js + * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'}) + * let textMetrics = PIXI.TextMetrics.measureText('Your text', style) + * ``` + * * @class * @memberOf PIXI */ @@ -103,85 +108,396 @@ { const context = canvas.getContext('2d'); - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - const characterCache = {}; + let width = 0; + let line = ''; + let lines = ''; - for (let i = 0; i < lines.length; i++) + const cache = {}; + const { letterSpacing, whiteSpace } = style; + + // How to handle whitespaces + const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace); + const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace); + + // whether or not spaces may be added to the beginning of lines + let canPrependSpaces = !collapseSpaces; + + // There is letterSpacing after every char except the last one + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_! + // so for convenience the above needs to be compared to width + 1 extra letterSpace + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_ + // ________________________________________________ + // And then the final space is simply no appended to each line + const wordWrapWidth = style.wordWrapWidth + letterSpacing; + + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); + + for (let i = 0; i < tokens.length; i++) { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); + // get the word, space or newlineChar + let token = tokens[i]; - for (let j = 0; j < words.length; j++) + // if word is a new line + if (TextMetrics.isNewline(token)) { - const wordWidth = context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) + // keep the new line + if (!collapseNewlines) { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const character = characters[c]; - let characterWidth = characterCache[character]; - - if (characterWidth === undefined) - { - characterWidth = context.measureText(character).width; - characterCache[character] = characterWidth; - } - - if (characterWidth > spaceLeft) - { - result += `\n${character}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += character; - spaceLeft -= characterWidth; - } - } + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; } - else - { - const wordWidthWithSpace = wordWidth + context.measureText(' ').width; - if (j === 0 || wordWidthWithSpace > spaceLeft) - { - // Skip printing the newline if it's the first word of the line that is - // greater than the word wrap width. - if (j > 0) - { - result += '\n'; - } - result += words[j]; - spaceLeft = wordWrapWidth - wordWidth; - } - else - { - spaceLeft -= wordWidthWithSpace; - result += ` ${words[j]}`; - } + // if we should collapse new lines + // we simply convert it into a space + token = ' '; + } + + // if we should collapse repeated whitespaces + if (collapseSpaces) + { + // check both this and the last tokens for spaces + const currIsBreakingSpace = TextMetrics.isBreakingSpace(token); + const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]); + + if (currIsBreakingSpace && lastIsBreakingSpace) + { + continue; } } - if (i < lines.length - 1) + // get word width from cache if possible + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); + + // word is longer than desired bounds + if (tokenWidth > wordWrapWidth) { - result += '\n'; + // if we are not already at the beginning of a line + if (line !== '') + { + // start newlines for overflow words + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // break large word over multiple lines + if (TextMetrics.canBreakWords(token, style.breakWords)) + { + // break word into characters + const characters = token.split(''); + + // loop the characters + for (let j = 0; j < characters.length; j++) + { + let char = characters[j]; + + let k = 1; + // we are not at the end of the token + + while (characters[j + k]) + { + const nextChar = characters[j + k]; + const lastChar = char[char.length - 1]; + + // should not split chars + if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords)) + { + // combine chars & move forward one + char += nextChar; + } + else + { + break; + } + + k++; + } + + j += char.length - 1; + + const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context); + + if (characterWidth + width > wordWrapWidth) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = false; + line = ''; + width = 0; + } + + line += char; + width += characterWidth; + } + } + + // run word out of the bounds + else + { + // if there are words in this line already + // finish that line and start a new one + if (line.length > 0) + { + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // give it its own line + lines += TextMetrics.addLine(token); + canPrependSpaces = false; + line = ''; + width = 0; + } + } + + // word could fit + else + { + // word won't fit because of existing words + // start a new line + if (tokenWidth + width > wordWrapWidth) + { + // if its a space we don't want it + canPrependSpaces = false; + + // add a new line + lines += TextMetrics.addLine(line); + + // start a new line + line = ''; + width = 0; + } + + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) + { + // add the word to the current line + line += token; + + // update width counter + width += tokenWidth; + } } } - return result; + lines += TextMetrics.addLine(line, false); + + return lines; + } + + /** + * Convienience function for logging each line added during the wordWrap + * method + * + * @private + * @param {string} line - The line of text to add + * @param {boolean} newLine - Add new line character to end + * @return {string} A formatted line + */ + static addLine(line, newLine = true) + { + line = TextMetrics.trimRight(line); + + line = (newLine) ? `${line}\n` : line; + + return line; + } + + /** + * Gets & sets the widths of calculated characters in a cache object + * + * @private + * @param {string} key The key + * @param {number} letterSpacing The letter spacing + * @param {object} cache The cache + * @param {CanvasRenderingContext2D} context The canvas context + * @return {number} The from cache. + */ + static getFromCache(key, letterSpacing, cache, context) + { + let width = cache[key]; + + if (width === undefined) + { + const spacing = ((key.length) * letterSpacing); + + width = context.measureText(key).width + spacing; + cache[key] = width; + } + + return width; + } + + /** + * Determines whether we should collapse breaking spaces + * + * @private + * @param {string} whiteSpace The TextStyle property whiteSpace + * @return {boolean} should collapse + */ + static collapseSpaces(whiteSpace) + { + return (whiteSpace === 'normal' || whiteSpace === 'pre-line'); + } + + /** + * Determines whether we should collapse newLine chars + * + * @private + * @param {string} whiteSpace The white space + * @return {boolean} should collapse + */ + static collapseNewlines(whiteSpace) + { + return (whiteSpace === 'normal'); + } + + /** + * trims breaking whitespaces from string + * + * @private + * @param {string} text The text + * @return {string} trimmed string + */ + static trimRight(text) + { + if (typeof text !== 'string') + { + return ''; + } + + for (let i = text.length - 1; i >= 0; i--) + { + const char = text[i]; + + if (!TextMetrics.isBreakingSpace(char)) + { + break; + } + + text = text.slice(0, -1); + } + + return text; + } + + /** + * Determines if char is a newline. + * + * @private + * @param {string} char The character + * @return {boolean} True if newline, False otherwise. + */ + static isNewline(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Determines if char is a breaking whitespace. + * + * @private + * @param {string} char The character + * @return {boolean} True if whitespace, False otherwise. + */ + static isBreakingSpace(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Splits a string into words, breaking-spaces and newLine characters + * + * @private + * @param {string} text The text + * @return {array} A tokenized array + */ + static tokenize(text) + { + const tokens = []; + let token = ''; + + if (typeof text !== 'string') + { + return tokens; + } + + for (let i = 0; i < text.length; i++) + { + const char = text[i]; + + if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char)) + { + if (token !== '') + { + tokens.push(token); + token = ''; + } + + tokens.push(char); + + continue; + } + + token += char; + } + + if (token !== '') + { + tokens.push(token); + } + + return tokens; + } + + /** + * This method exists to be easily overridden + * It allows one to customise which words should break + * Examples are if the token is CJK or numbers. + * It must return a boolean. + * + * @private + * @param {string} token The token + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakWords(token, breakWords) + { + return breakWords; + } + + /** + * This method exists to be easily overridden + * It allows one to determine whether a pair of characters + * should be broken by newlines + * For example certain characters in CJK langs or numbers. + * It must return a boolean. + * + * @private + * @param {string} char The character + * @param {string} nextChar The next character + * @param {string} token The token/word the characters are from + * @param {number} index The index in the token of the char + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakChars(char, nextChar, token, index, breakWords) // eslint-disable-line no-unused-vars + { + return true; } /** @@ -325,3 +641,37 @@ * @private */ TextMetrics._fonts = {}; + +/** + * Cache of new line chars. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._newlines = [ + 0x000A, // line feed + 0x000D, // carriage return +]; + +/** + * Cache of breaking spaces. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._breakingSpaces = [ + 0x0009, // character tabulation + 0x0020, // space + 0x2000, // en quad + 0x2001, // em quad + 0x2002, // en space + 0x2003, // em space + 0x2004, // three-per-em space + 0x2005, // four-per-em space + 0x2006, // six-per-em space + 0x2008, // punctuation space + 0x2009, // thin space + 0x200A, // hair space + 0x205F, // medium mathematical space + 0x3000, // ideographic space +]; diff --git a/packages/text/src/TextMetrics.js b/packages/text/src/TextMetrics.js index c4f32e1..f4c6c0c 100644 --- a/packages/text/src/TextMetrics.js +++ b/packages/text/src/TextMetrics.js @@ -1,6 +1,11 @@ /** * The TextMetrics object represents the measurement of a block of text with a specified style. * + * ```js + * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'}) + * let textMetrics = PIXI.TextMetrics.measureText('Your text', style) + * ``` + * * @class * @memberOf PIXI */ @@ -103,85 +108,396 @@ { const context = canvas.getContext('2d'); - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - const characterCache = {}; + let width = 0; + let line = ''; + let lines = ''; - for (let i = 0; i < lines.length; i++) + const cache = {}; + const { letterSpacing, whiteSpace } = style; + + // How to handle whitespaces + const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace); + const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace); + + // whether or not spaces may be added to the beginning of lines + let canPrependSpaces = !collapseSpaces; + + // There is letterSpacing after every char except the last one + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_! + // so for convenience the above needs to be compared to width + 1 extra letterSpace + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_ + // ________________________________________________ + // And then the final space is simply no appended to each line + const wordWrapWidth = style.wordWrapWidth + letterSpacing; + + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); + + for (let i = 0; i < tokens.length; i++) { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); + // get the word, space or newlineChar + let token = tokens[i]; - for (let j = 0; j < words.length; j++) + // if word is a new line + if (TextMetrics.isNewline(token)) { - const wordWidth = context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) + // keep the new line + if (!collapseNewlines) { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const character = characters[c]; - let characterWidth = characterCache[character]; - - if (characterWidth === undefined) - { - characterWidth = context.measureText(character).width; - characterCache[character] = characterWidth; - } - - if (characterWidth > spaceLeft) - { - result += `\n${character}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += character; - spaceLeft -= characterWidth; - } - } + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; } - else - { - const wordWidthWithSpace = wordWidth + context.measureText(' ').width; - if (j === 0 || wordWidthWithSpace > spaceLeft) - { - // Skip printing the newline if it's the first word of the line that is - // greater than the word wrap width. - if (j > 0) - { - result += '\n'; - } - result += words[j]; - spaceLeft = wordWrapWidth - wordWidth; - } - else - { - spaceLeft -= wordWidthWithSpace; - result += ` ${words[j]}`; - } + // if we should collapse new lines + // we simply convert it into a space + token = ' '; + } + + // if we should collapse repeated whitespaces + if (collapseSpaces) + { + // check both this and the last tokens for spaces + const currIsBreakingSpace = TextMetrics.isBreakingSpace(token); + const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]); + + if (currIsBreakingSpace && lastIsBreakingSpace) + { + continue; } } - if (i < lines.length - 1) + // get word width from cache if possible + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); + + // word is longer than desired bounds + if (tokenWidth > wordWrapWidth) { - result += '\n'; + // if we are not already at the beginning of a line + if (line !== '') + { + // start newlines for overflow words + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // break large word over multiple lines + if (TextMetrics.canBreakWords(token, style.breakWords)) + { + // break word into characters + const characters = token.split(''); + + // loop the characters + for (let j = 0; j < characters.length; j++) + { + let char = characters[j]; + + let k = 1; + // we are not at the end of the token + + while (characters[j + k]) + { + const nextChar = characters[j + k]; + const lastChar = char[char.length - 1]; + + // should not split chars + if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords)) + { + // combine chars & move forward one + char += nextChar; + } + else + { + break; + } + + k++; + } + + j += char.length - 1; + + const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context); + + if (characterWidth + width > wordWrapWidth) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = false; + line = ''; + width = 0; + } + + line += char; + width += characterWidth; + } + } + + // run word out of the bounds + else + { + // if there are words in this line already + // finish that line and start a new one + if (line.length > 0) + { + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // give it its own line + lines += TextMetrics.addLine(token); + canPrependSpaces = false; + line = ''; + width = 0; + } + } + + // word could fit + else + { + // word won't fit because of existing words + // start a new line + if (tokenWidth + width > wordWrapWidth) + { + // if its a space we don't want it + canPrependSpaces = false; + + // add a new line + lines += TextMetrics.addLine(line); + + // start a new line + line = ''; + width = 0; + } + + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) + { + // add the word to the current line + line += token; + + // update width counter + width += tokenWidth; + } } } - return result; + lines += TextMetrics.addLine(line, false); + + return lines; + } + + /** + * Convienience function for logging each line added during the wordWrap + * method + * + * @private + * @param {string} line - The line of text to add + * @param {boolean} newLine - Add new line character to end + * @return {string} A formatted line + */ + static addLine(line, newLine = true) + { + line = TextMetrics.trimRight(line); + + line = (newLine) ? `${line}\n` : line; + + return line; + } + + /** + * Gets & sets the widths of calculated characters in a cache object + * + * @private + * @param {string} key The key + * @param {number} letterSpacing The letter spacing + * @param {object} cache The cache + * @param {CanvasRenderingContext2D} context The canvas context + * @return {number} The from cache. + */ + static getFromCache(key, letterSpacing, cache, context) + { + let width = cache[key]; + + if (width === undefined) + { + const spacing = ((key.length) * letterSpacing); + + width = context.measureText(key).width + spacing; + cache[key] = width; + } + + return width; + } + + /** + * Determines whether we should collapse breaking spaces + * + * @private + * @param {string} whiteSpace The TextStyle property whiteSpace + * @return {boolean} should collapse + */ + static collapseSpaces(whiteSpace) + { + return (whiteSpace === 'normal' || whiteSpace === 'pre-line'); + } + + /** + * Determines whether we should collapse newLine chars + * + * @private + * @param {string} whiteSpace The white space + * @return {boolean} should collapse + */ + static collapseNewlines(whiteSpace) + { + return (whiteSpace === 'normal'); + } + + /** + * trims breaking whitespaces from string + * + * @private + * @param {string} text The text + * @return {string} trimmed string + */ + static trimRight(text) + { + if (typeof text !== 'string') + { + return ''; + } + + for (let i = text.length - 1; i >= 0; i--) + { + const char = text[i]; + + if (!TextMetrics.isBreakingSpace(char)) + { + break; + } + + text = text.slice(0, -1); + } + + return text; + } + + /** + * Determines if char is a newline. + * + * @private + * @param {string} char The character + * @return {boolean} True if newline, False otherwise. + */ + static isNewline(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Determines if char is a breaking whitespace. + * + * @private + * @param {string} char The character + * @return {boolean} True if whitespace, False otherwise. + */ + static isBreakingSpace(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Splits a string into words, breaking-spaces and newLine characters + * + * @private + * @param {string} text The text + * @return {array} A tokenized array + */ + static tokenize(text) + { + const tokens = []; + let token = ''; + + if (typeof text !== 'string') + { + return tokens; + } + + for (let i = 0; i < text.length; i++) + { + const char = text[i]; + + if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char)) + { + if (token !== '') + { + tokens.push(token); + token = ''; + } + + tokens.push(char); + + continue; + } + + token += char; + } + + if (token !== '') + { + tokens.push(token); + } + + return tokens; + } + + /** + * This method exists to be easily overridden + * It allows one to customise which words should break + * Examples are if the token is CJK or numbers. + * It must return a boolean. + * + * @private + * @param {string} token The token + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakWords(token, breakWords) + { + return breakWords; + } + + /** + * This method exists to be easily overridden + * It allows one to determine whether a pair of characters + * should be broken by newlines + * For example certain characters in CJK langs or numbers. + * It must return a boolean. + * + * @private + * @param {string} char The character + * @param {string} nextChar The next character + * @param {string} token The token/word the characters are from + * @param {number} index The index in the token of the char + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakChars(char, nextChar, token, index, breakWords) // eslint-disable-line no-unused-vars + { + return true; } /** @@ -325,3 +641,37 @@ * @private */ TextMetrics._fonts = {}; + +/** + * Cache of new line chars. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._newlines = [ + 0x000A, // line feed + 0x000D, // carriage return +]; + +/** + * Cache of breaking spaces. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._breakingSpaces = [ + 0x0009, // character tabulation + 0x0020, // space + 0x2000, // en quad + 0x2001, // em quad + 0x2002, // en space + 0x2003, // em space + 0x2004, // three-per-em space + 0x2005, // four-per-em space + 0x2006, // six-per-em space + 0x2008, // punctuation space + 0x2009, // thin space + 0x200A, // hair space + 0x205F, // medium mathematical space + 0x3000, // ideographic space +]; diff --git a/packages/text/src/TextStyle.js b/packages/text/src/TextStyle.js index b37d69e..e7b7400 100644 --- a/packages/text/src/TextStyle.js +++ b/packages/text/src/TextStyle.js @@ -30,6 +30,7 @@ strokeThickness: 0, textBaseline: 'alphabetic', trim: false, + whiteSpace: 'pre', wordWrap: false, wordWrapWidth: 100, leading: 0, @@ -87,6 +88,8 @@ * Default is 0 (no stroke) * @param {boolean} [style.trim=false] - Trim transparent borders * @param {string} [style.textBaseline='alphabetic'] - The baseline of the text that is rendered. + * @param {boolean} [style.whiteSpace='pre'] - Determines whether newlines & spaces are collapsed or preserved "normal" + * (collapse, collapse), "pre" (preserve, preserve) | "pre-line" (preserve, collapse). It needs wordWrap to be set to true * @param {boolean} [style.wordWrap=false] - Indicates if word wrap should be used * @param {number} [style.wordWrapWidth=100] - The width at which text will wrap, it needs wordWrap to be set to true */ @@ -607,6 +610,31 @@ } /** + * How newlines and spaces should be handled. + * Default is 'pre' (preserve, preserve). + * + * value | New lines | Spaces + * --- | --- | --- + * 'normal' | Collapse | Collapse + * 'pre' | Preserve | Preserve + * 'pre-line' | Preserve | Collapse + * + * @member {string} + */ + get whiteSpace() + { + return this._whiteSpace; + } + set whiteSpace(whiteSpace) // eslint-disable-line require-jsdoc + { + if (this._whiteSpace !== whiteSpace) + { + this._whiteSpace = whiteSpace; + this.styleID++; + } + } + + /** * Indicates if word wrap should be used * * @member {boolean} diff --git a/packages/text/src/TextMetrics.js b/packages/text/src/TextMetrics.js index c4f32e1..f4c6c0c 100644 --- a/packages/text/src/TextMetrics.js +++ b/packages/text/src/TextMetrics.js @@ -1,6 +1,11 @@ /** * The TextMetrics object represents the measurement of a block of text with a specified style. * + * ```js + * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'}) + * let textMetrics = PIXI.TextMetrics.measureText('Your text', style) + * ``` + * * @class * @memberOf PIXI */ @@ -103,85 +108,396 @@ { const context = canvas.getContext('2d'); - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - const characterCache = {}; + let width = 0; + let line = ''; + let lines = ''; - for (let i = 0; i < lines.length; i++) + const cache = {}; + const { letterSpacing, whiteSpace } = style; + + // How to handle whitespaces + const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace); + const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace); + + // whether or not spaces may be added to the beginning of lines + let canPrependSpaces = !collapseSpaces; + + // There is letterSpacing after every char except the last one + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_! + // so for convenience the above needs to be compared to width + 1 extra letterSpace + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_ + // ________________________________________________ + // And then the final space is simply no appended to each line + const wordWrapWidth = style.wordWrapWidth + letterSpacing; + + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); + + for (let i = 0; i < tokens.length; i++) { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); + // get the word, space or newlineChar + let token = tokens[i]; - for (let j = 0; j < words.length; j++) + // if word is a new line + if (TextMetrics.isNewline(token)) { - const wordWidth = context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) + // keep the new line + if (!collapseNewlines) { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const character = characters[c]; - let characterWidth = characterCache[character]; - - if (characterWidth === undefined) - { - characterWidth = context.measureText(character).width; - characterCache[character] = characterWidth; - } - - if (characterWidth > spaceLeft) - { - result += `\n${character}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += character; - spaceLeft -= characterWidth; - } - } + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; } - else - { - const wordWidthWithSpace = wordWidth + context.measureText(' ').width; - if (j === 0 || wordWidthWithSpace > spaceLeft) - { - // Skip printing the newline if it's the first word of the line that is - // greater than the word wrap width. - if (j > 0) - { - result += '\n'; - } - result += words[j]; - spaceLeft = wordWrapWidth - wordWidth; - } - else - { - spaceLeft -= wordWidthWithSpace; - result += ` ${words[j]}`; - } + // if we should collapse new lines + // we simply convert it into a space + token = ' '; + } + + // if we should collapse repeated whitespaces + if (collapseSpaces) + { + // check both this and the last tokens for spaces + const currIsBreakingSpace = TextMetrics.isBreakingSpace(token); + const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]); + + if (currIsBreakingSpace && lastIsBreakingSpace) + { + continue; } } - if (i < lines.length - 1) + // get word width from cache if possible + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); + + // word is longer than desired bounds + if (tokenWidth > wordWrapWidth) { - result += '\n'; + // if we are not already at the beginning of a line + if (line !== '') + { + // start newlines for overflow words + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // break large word over multiple lines + if (TextMetrics.canBreakWords(token, style.breakWords)) + { + // break word into characters + const characters = token.split(''); + + // loop the characters + for (let j = 0; j < characters.length; j++) + { + let char = characters[j]; + + let k = 1; + // we are not at the end of the token + + while (characters[j + k]) + { + const nextChar = characters[j + k]; + const lastChar = char[char.length - 1]; + + // should not split chars + if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords)) + { + // combine chars & move forward one + char += nextChar; + } + else + { + break; + } + + k++; + } + + j += char.length - 1; + + const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context); + + if (characterWidth + width > wordWrapWidth) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = false; + line = ''; + width = 0; + } + + line += char; + width += characterWidth; + } + } + + // run word out of the bounds + else + { + // if there are words in this line already + // finish that line and start a new one + if (line.length > 0) + { + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // give it its own line + lines += TextMetrics.addLine(token); + canPrependSpaces = false; + line = ''; + width = 0; + } + } + + // word could fit + else + { + // word won't fit because of existing words + // start a new line + if (tokenWidth + width > wordWrapWidth) + { + // if its a space we don't want it + canPrependSpaces = false; + + // add a new line + lines += TextMetrics.addLine(line); + + // start a new line + line = ''; + width = 0; + } + + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) + { + // add the word to the current line + line += token; + + // update width counter + width += tokenWidth; + } } } - return result; + lines += TextMetrics.addLine(line, false); + + return lines; + } + + /** + * Convienience function for logging each line added during the wordWrap + * method + * + * @private + * @param {string} line - The line of text to add + * @param {boolean} newLine - Add new line character to end + * @return {string} A formatted line + */ + static addLine(line, newLine = true) + { + line = TextMetrics.trimRight(line); + + line = (newLine) ? `${line}\n` : line; + + return line; + } + + /** + * Gets & sets the widths of calculated characters in a cache object + * + * @private + * @param {string} key The key + * @param {number} letterSpacing The letter spacing + * @param {object} cache The cache + * @param {CanvasRenderingContext2D} context The canvas context + * @return {number} The from cache. + */ + static getFromCache(key, letterSpacing, cache, context) + { + let width = cache[key]; + + if (width === undefined) + { + const spacing = ((key.length) * letterSpacing); + + width = context.measureText(key).width + spacing; + cache[key] = width; + } + + return width; + } + + /** + * Determines whether we should collapse breaking spaces + * + * @private + * @param {string} whiteSpace The TextStyle property whiteSpace + * @return {boolean} should collapse + */ + static collapseSpaces(whiteSpace) + { + return (whiteSpace === 'normal' || whiteSpace === 'pre-line'); + } + + /** + * Determines whether we should collapse newLine chars + * + * @private + * @param {string} whiteSpace The white space + * @return {boolean} should collapse + */ + static collapseNewlines(whiteSpace) + { + return (whiteSpace === 'normal'); + } + + /** + * trims breaking whitespaces from string + * + * @private + * @param {string} text The text + * @return {string} trimmed string + */ + static trimRight(text) + { + if (typeof text !== 'string') + { + return ''; + } + + for (let i = text.length - 1; i >= 0; i--) + { + const char = text[i]; + + if (!TextMetrics.isBreakingSpace(char)) + { + break; + } + + text = text.slice(0, -1); + } + + return text; + } + + /** + * Determines if char is a newline. + * + * @private + * @param {string} char The character + * @return {boolean} True if newline, False otherwise. + */ + static isNewline(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Determines if char is a breaking whitespace. + * + * @private + * @param {string} char The character + * @return {boolean} True if whitespace, False otherwise. + */ + static isBreakingSpace(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Splits a string into words, breaking-spaces and newLine characters + * + * @private + * @param {string} text The text + * @return {array} A tokenized array + */ + static tokenize(text) + { + const tokens = []; + let token = ''; + + if (typeof text !== 'string') + { + return tokens; + } + + for (let i = 0; i < text.length; i++) + { + const char = text[i]; + + if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char)) + { + if (token !== '') + { + tokens.push(token); + token = ''; + } + + tokens.push(char); + + continue; + } + + token += char; + } + + if (token !== '') + { + tokens.push(token); + } + + return tokens; + } + + /** + * This method exists to be easily overridden + * It allows one to customise which words should break + * Examples are if the token is CJK or numbers. + * It must return a boolean. + * + * @private + * @param {string} token The token + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakWords(token, breakWords) + { + return breakWords; + } + + /** + * This method exists to be easily overridden + * It allows one to determine whether a pair of characters + * should be broken by newlines + * For example certain characters in CJK langs or numbers. + * It must return a boolean. + * + * @private + * @param {string} char The character + * @param {string} nextChar The next character + * @param {string} token The token/word the characters are from + * @param {number} index The index in the token of the char + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakChars(char, nextChar, token, index, breakWords) // eslint-disable-line no-unused-vars + { + return true; } /** @@ -325,3 +641,37 @@ * @private */ TextMetrics._fonts = {}; + +/** + * Cache of new line chars. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._newlines = [ + 0x000A, // line feed + 0x000D, // carriage return +]; + +/** + * Cache of breaking spaces. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._breakingSpaces = [ + 0x0009, // character tabulation + 0x0020, // space + 0x2000, // en quad + 0x2001, // em quad + 0x2002, // en space + 0x2003, // em space + 0x2004, // three-per-em space + 0x2005, // four-per-em space + 0x2006, // six-per-em space + 0x2008, // punctuation space + 0x2009, // thin space + 0x200A, // hair space + 0x205F, // medium mathematical space + 0x3000, // ideographic space +]; diff --git a/packages/text/src/TextStyle.js b/packages/text/src/TextStyle.js index b37d69e..e7b7400 100644 --- a/packages/text/src/TextStyle.js +++ b/packages/text/src/TextStyle.js @@ -30,6 +30,7 @@ strokeThickness: 0, textBaseline: 'alphabetic', trim: false, + whiteSpace: 'pre', wordWrap: false, wordWrapWidth: 100, leading: 0, @@ -87,6 +88,8 @@ * Default is 0 (no stroke) * @param {boolean} [style.trim=false] - Trim transparent borders * @param {string} [style.textBaseline='alphabetic'] - The baseline of the text that is rendered. + * @param {boolean} [style.whiteSpace='pre'] - Determines whether newlines & spaces are collapsed or preserved "normal" + * (collapse, collapse), "pre" (preserve, preserve) | "pre-line" (preserve, collapse). It needs wordWrap to be set to true * @param {boolean} [style.wordWrap=false] - Indicates if word wrap should be used * @param {number} [style.wordWrapWidth=100] - The width at which text will wrap, it needs wordWrap to be set to true */ @@ -607,6 +610,31 @@ } /** + * How newlines and spaces should be handled. + * Default is 'pre' (preserve, preserve). + * + * value | New lines | Spaces + * --- | --- | --- + * 'normal' | Collapse | Collapse + * 'pre' | Preserve | Preserve + * 'pre-line' | Preserve | Collapse + * + * @member {string} + */ + get whiteSpace() + { + return this._whiteSpace; + } + set whiteSpace(whiteSpace) // eslint-disable-line require-jsdoc + { + if (this._whiteSpace !== whiteSpace) + { + this._whiteSpace = whiteSpace; + this.styleID++; + } + } + + /** * Indicates if word wrap should be used * * @member {boolean} diff --git a/packages/text/test/TextMetrics.js b/packages/text/test/TextMetrics.js new file mode 100644 index 0000000..c7bc29a --- /dev/null +++ b/packages/text/test/TextMetrics.js @@ -0,0 +1,610 @@ +const { TextMetrics, TextStyle } = require('../'); + +/* eslint-disable no-multi-str */ +const longText = 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem \ +accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo \ +inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo \ +enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia \ +consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro \ +quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, \ +sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam \ +quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam \ +corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis \ +autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil \ +molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla \ +pariatur?'; + +/* eslint-disable max-len */ +const spaceNewLineText = ' Should have\u0009space\u2003at the\u2000beginning of the line.\n And 3 more here. But after that there should be no\u3000more ridiculous spaces at the beginning of lines. And none at the end. And all this text is just to check the wrapping abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz. I \u2665 text. 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 '; +const breakingWordText = 'Pixi.js - The HTML5 Creation Engine. Create beautiful digital content with the supercalifragilisticexpialidociously fastest, most flexible 2D WebGL renderer.'; +const fillText = '. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . '; +const intergityText = '012345678901234567890123456789'; +const nonBreakingSpaces = ['\u00A0', '\u2007', '\u202F']; + +const breakingSpaces = [ + '\u0009', + '\u0020', + '\u2000', + '\u2001', + '\u2002', + '\u2003', + '\u2004', + '\u2005', + '\u2006', + '\u2008', + '\u2009', + '\u200A', + '\u205F', + '\u3000', +]; + +describe('PIXI.TextMetrics', function () +{ + const defaultStyle = { + breakWords: true, + fontFamily: 'Arial', + fontSize: 20, + fontStyle: 'italic', + fontVariant: 'normal', + fontWeight: 900, + wordWrap: true, + wordWrapWidth: 200, + letterSpacing: 4, + }; + + describe('wordWrap without breakWords', function () + { + it('width should not be greater than wordWrapWidth with longText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(longText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be greater than wordWrapWidth with breakingWordText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(breakingWordText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be within a character width from wordWrapWidth with fillText', function () + { + const charWidth = 4; // it should fill the line to at lease width -4 + + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(fillText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + expect(metrics.width + charWidth).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be greater than wordWrapWidth and should format correct spaces', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal(' ', '1st line should start with a space'); + expect(metrics.lines[4][0]).to.equal(' ', '5th line should start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal(' ', '5th line should start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal(' ', '5th line should start with 3 spaces (3)'); + expect(metrics.lines[4][3]).to.not.equal(' ', '5th line should not have a space as the 4th char'); + + metrics.lines.forEach((line, i) => + { + if (i !== 0 && i !== 4) + { + expect(metrics.lines[1][0]).to.not.equal(' ', 'all lines except 1 & 5 should not have space at the start'); + } + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('wordWrap with breakWords', function () + { + it('width should not be greater than wordWrapWidth with longText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(longText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should not be greater than wordWrapWidth with breakingWordAtStartText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(breakingWordText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be within a character width from wordWrapWidth with fillText', function () + { + const charWidth = 4; // it should fill the line to at lease width -4 + + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(fillText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + expect(metrics.width + charWidth).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('no words or characters should lost or changed', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(intergityText, new TextStyle(style)); + + const lines = metrics.lines.reduce((accumulator, line) => accumulator + line); + + expect(lines).to.equal(intergityText, 'should have the same chars as the original text'); + }); + + it('width should not be greater than wordWrapWidth and should format correct spaces', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal(' ', '1st line should start with a space'); + expect(metrics.lines[4][0]).to.equal(' ', '5th line should start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal(' ', '5th line should start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal(' ', '5th line should start with 3 spaces (3)'); + expect(metrics.lines[4][3]).to.not.equal(' ', '5th line should not have a space as the 4th char'); + + metrics.lines.forEach((line, i) => + { + if (i !== 0 && i !== 4) + { + expect(metrics.lines[1][0]).to.not.equal(' ', 'all lines except 1 & 5 should not have space at the start'); + } + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `normal` without breakWords', function () + { + it('multiple spaces should be collapsed to 1 and but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false, whiteSpace: 'normal' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('m', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('o', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('r', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('a', '17th line should not have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `pre-line` without breakWords', function () + { + it('multiple spaces should be collapsed to 1 but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false, whiteSpace: 'pre-line' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('A', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('n', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('d', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('t', '17th line should have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `normal` with breakWords', function () + { + it('multiple spaces should be collapsed to 1 and but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true, whiteSpace: 'normal' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('m', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('o', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('r', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('a', '17th line should not have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `pre-line` with breakWords', function () + { + it('multiple spaces should be collapsed to 1 but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true, whiteSpace: 'pre-line' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('A', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('n', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('d', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('t', '17th line should have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('trimRight', function () + { + it('string with no whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right'); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('string with whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right '); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('string with strange unicode whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right\u0009\u0020\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2008\u2009\u200A\u205F\u3000'); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('empty string', function () + { + const text = TextMetrics.trimRight(''); + + expect(text).to.equal(''); + }); + + it('non-string input', function () + { + const text = TextMetrics.trimRight({}); + + expect(text).to.equal(''); + }); + }); + + describe('isNewline', function () + { + it('line feed', function () + { + const bool = TextMetrics.isNewline('\n'); + + expect(bool).to.equal(true); + }); + + it('carriage return', function () + { + const bool = TextMetrics.isNewline('\r'); + + expect(bool).to.equal(true); + }); + + it('newline char', function () + { + const bool = TextMetrics.isNewline('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.isNewline({}); + + expect(bool).to.equal(false); + }); + }); + + describe('isBreakingSpace', function () + { + it('legit breaking spaces', function () + { + breakingSpaces.forEach((char) => + { + const bool = TextMetrics.isBreakingSpace(char); + + expect(bool).to.equal(true); + }); + }); + + it('non breaking spaces', function () + { + nonBreakingSpaces.forEach((char) => + { + const bool = TextMetrics.isBreakingSpace(char); + + expect(bool).to.not.equal(true); + }); + }); + + it('newline char', function () + { + const bool = TextMetrics.isBreakingSpace('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.isBreakingSpace({}); + + expect(bool).to.equal(false); + }); + }); + + describe('tokenize', function () + { + it('full example', function () + { + const arr = TextMetrics.tokenize(spaceNewLineText); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(146); + expect(arr).to.not.contain(''); + expect(arr).to.not.contain(null); + }); + + it('empty string', function () + { + const arr = TextMetrics.tokenize(''); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + + it('single char', function () + { + const arr = TextMetrics.tokenize('A'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('newline char', function () + { + const arr = TextMetrics.tokenize('\n'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('breakingSpaces', function () + { + const arr = TextMetrics.tokenize(breakingSpaces.join('')); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(breakingSpaces.length); + }); + + it('non string', function () + { + const arr = TextMetrics.tokenize({}); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + }); + + describe('collapseSpaces', function () + { + it('pre', function () + { + const bool = TextMetrics.collapseSpaces('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = TextMetrics.collapseSpaces('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = TextMetrics.collapseSpaces('pre-line'); + + expect(bool).to.equal(true); + }); + + it('non matching string', function () + { + const bool = TextMetrics.collapseSpaces('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.collapseSpaces({}); + + expect(bool).to.equal(false); + }); + }); + + describe('collapseNewlines', function () + { + it('pre', function () + { + const bool = TextMetrics.collapseNewlines('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = TextMetrics.collapseNewlines('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = TextMetrics.collapseNewlines('pre-line'); + + expect(bool).to.equal(false); + }); + + it('non matching string', function () + { + const bool = TextMetrics.collapseNewlines('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.collapseNewlines({}); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakWords', function () + { + it('breakWords: true', function () + { + const bool = TextMetrics.canBreakWords('text', true); + + expect(bool).to.equal(true); + }); + + it('breakWords: false', function () + { + const bool = TextMetrics.canBreakWords('text', false); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakChars', function () + { + it('should always return true', function () + { + const bool = TextMetrics.canBreakChars(); + + expect(bool).to.equal(true); + }); + + it('should prevent breaking for all numbers', function () + { + const style = new TextStyle({ + fontFamily: 'Arial', + fontSize: 26, + fontStyle: 'italic', + fontVariant: 'normal', + fontWeight: 900, + wordWrap: true, + wordWrapWidth: 300, + letterSpacing: 4, + padding: 10, + fill: 0xffffff, + breakWords: false, + whiteSpace: 'pre-line', + }); + + const str = '-------0000,1111,9999------'; + const reg = /^\d+$/; + + TextMetrics.canBreakWords = function () + { + return true; + }; + + // override breakChars + TextMetrics.canBreakChars = function (char, nextChar) + { + return !(char.match(reg) && nextChar.match(reg)); + }; + + const metrics = TextMetrics.measureText(str, style); + + expect(metrics.lines[0]).to.equal('-------0000,1111,'); + expect(metrics.lines[1]).to.equal('9999------'); + }); + }); +}); diff --git a/packages/text/src/TextMetrics.js b/packages/text/src/TextMetrics.js index c4f32e1..f4c6c0c 100644 --- a/packages/text/src/TextMetrics.js +++ b/packages/text/src/TextMetrics.js @@ -1,6 +1,11 @@ /** * The TextMetrics object represents the measurement of a block of text with a specified style. * + * ```js + * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'}) + * let textMetrics = PIXI.TextMetrics.measureText('Your text', style) + * ``` + * * @class * @memberOf PIXI */ @@ -103,85 +108,396 @@ { const context = canvas.getContext('2d'); - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - const characterCache = {}; + let width = 0; + let line = ''; + let lines = ''; - for (let i = 0; i < lines.length; i++) + const cache = {}; + const { letterSpacing, whiteSpace } = style; + + // How to handle whitespaces + const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace); + const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace); + + // whether or not spaces may be added to the beginning of lines + let canPrependSpaces = !collapseSpaces; + + // There is letterSpacing after every char except the last one + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_! + // so for convenience the above needs to be compared to width + 1 extra letterSpace + // t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_ + // ________________________________________________ + // And then the final space is simply no appended to each line + const wordWrapWidth = style.wordWrapWidth + letterSpacing; + + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); + + for (let i = 0; i < tokens.length; i++) { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); + // get the word, space or newlineChar + let token = tokens[i]; - for (let j = 0; j < words.length; j++) + // if word is a new line + if (TextMetrics.isNewline(token)) { - const wordWidth = context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) + // keep the new line + if (!collapseNewlines) { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const character = characters[c]; - let characterWidth = characterCache[character]; - - if (characterWidth === undefined) - { - characterWidth = context.measureText(character).width; - characterCache[character] = characterWidth; - } - - if (characterWidth > spaceLeft) - { - result += `\n${character}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += character; - spaceLeft -= characterWidth; - } - } + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; } - else - { - const wordWidthWithSpace = wordWidth + context.measureText(' ').width; - if (j === 0 || wordWidthWithSpace > spaceLeft) - { - // Skip printing the newline if it's the first word of the line that is - // greater than the word wrap width. - if (j > 0) - { - result += '\n'; - } - result += words[j]; - spaceLeft = wordWrapWidth - wordWidth; - } - else - { - spaceLeft -= wordWidthWithSpace; - result += ` ${words[j]}`; - } + // if we should collapse new lines + // we simply convert it into a space + token = ' '; + } + + // if we should collapse repeated whitespaces + if (collapseSpaces) + { + // check both this and the last tokens for spaces + const currIsBreakingSpace = TextMetrics.isBreakingSpace(token); + const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]); + + if (currIsBreakingSpace && lastIsBreakingSpace) + { + continue; } } - if (i < lines.length - 1) + // get word width from cache if possible + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); + + // word is longer than desired bounds + if (tokenWidth > wordWrapWidth) { - result += '\n'; + // if we are not already at the beginning of a line + if (line !== '') + { + // start newlines for overflow words + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // break large word over multiple lines + if (TextMetrics.canBreakWords(token, style.breakWords)) + { + // break word into characters + const characters = token.split(''); + + // loop the characters + for (let j = 0; j < characters.length; j++) + { + let char = characters[j]; + + let k = 1; + // we are not at the end of the token + + while (characters[j + k]) + { + const nextChar = characters[j + k]; + const lastChar = char[char.length - 1]; + + // should not split chars + if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords)) + { + // combine chars & move forward one + char += nextChar; + } + else + { + break; + } + + k++; + } + + j += char.length - 1; + + const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context); + + if (characterWidth + width > wordWrapWidth) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = false; + line = ''; + width = 0; + } + + line += char; + width += characterWidth; + } + } + + // run word out of the bounds + else + { + // if there are words in this line already + // finish that line and start a new one + if (line.length > 0) + { + lines += TextMetrics.addLine(line); + line = ''; + width = 0; + } + + // give it its own line + lines += TextMetrics.addLine(token); + canPrependSpaces = false; + line = ''; + width = 0; + } + } + + // word could fit + else + { + // word won't fit because of existing words + // start a new line + if (tokenWidth + width > wordWrapWidth) + { + // if its a space we don't want it + canPrependSpaces = false; + + // add a new line + lines += TextMetrics.addLine(line); + + // start a new line + line = ''; + width = 0; + } + + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) + { + // add the word to the current line + line += token; + + // update width counter + width += tokenWidth; + } } } - return result; + lines += TextMetrics.addLine(line, false); + + return lines; + } + + /** + * Convienience function for logging each line added during the wordWrap + * method + * + * @private + * @param {string} line - The line of text to add + * @param {boolean} newLine - Add new line character to end + * @return {string} A formatted line + */ + static addLine(line, newLine = true) + { + line = TextMetrics.trimRight(line); + + line = (newLine) ? `${line}\n` : line; + + return line; + } + + /** + * Gets & sets the widths of calculated characters in a cache object + * + * @private + * @param {string} key The key + * @param {number} letterSpacing The letter spacing + * @param {object} cache The cache + * @param {CanvasRenderingContext2D} context The canvas context + * @return {number} The from cache. + */ + static getFromCache(key, letterSpacing, cache, context) + { + let width = cache[key]; + + if (width === undefined) + { + const spacing = ((key.length) * letterSpacing); + + width = context.measureText(key).width + spacing; + cache[key] = width; + } + + return width; + } + + /** + * Determines whether we should collapse breaking spaces + * + * @private + * @param {string} whiteSpace The TextStyle property whiteSpace + * @return {boolean} should collapse + */ + static collapseSpaces(whiteSpace) + { + return (whiteSpace === 'normal' || whiteSpace === 'pre-line'); + } + + /** + * Determines whether we should collapse newLine chars + * + * @private + * @param {string} whiteSpace The white space + * @return {boolean} should collapse + */ + static collapseNewlines(whiteSpace) + { + return (whiteSpace === 'normal'); + } + + /** + * trims breaking whitespaces from string + * + * @private + * @param {string} text The text + * @return {string} trimmed string + */ + static trimRight(text) + { + if (typeof text !== 'string') + { + return ''; + } + + for (let i = text.length - 1; i >= 0; i--) + { + const char = text[i]; + + if (!TextMetrics.isBreakingSpace(char)) + { + break; + } + + text = text.slice(0, -1); + } + + return text; + } + + /** + * Determines if char is a newline. + * + * @private + * @param {string} char The character + * @return {boolean} True if newline, False otherwise. + */ + static isNewline(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Determines if char is a breaking whitespace. + * + * @private + * @param {string} char The character + * @return {boolean} True if whitespace, False otherwise. + */ + static isBreakingSpace(char) + { + if (typeof char !== 'string') + { + return false; + } + + return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0); + } + + /** + * Splits a string into words, breaking-spaces and newLine characters + * + * @private + * @param {string} text The text + * @return {array} A tokenized array + */ + static tokenize(text) + { + const tokens = []; + let token = ''; + + if (typeof text !== 'string') + { + return tokens; + } + + for (let i = 0; i < text.length; i++) + { + const char = text[i]; + + if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char)) + { + if (token !== '') + { + tokens.push(token); + token = ''; + } + + tokens.push(char); + + continue; + } + + token += char; + } + + if (token !== '') + { + tokens.push(token); + } + + return tokens; + } + + /** + * This method exists to be easily overridden + * It allows one to customise which words should break + * Examples are if the token is CJK or numbers. + * It must return a boolean. + * + * @private + * @param {string} token The token + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakWords(token, breakWords) + { + return breakWords; + } + + /** + * This method exists to be easily overridden + * It allows one to determine whether a pair of characters + * should be broken by newlines + * For example certain characters in CJK langs or numbers. + * It must return a boolean. + * + * @private + * @param {string} char The character + * @param {string} nextChar The next character + * @param {string} token The token/word the characters are from + * @param {number} index The index in the token of the char + * @param {boolean} breakWords The style attr break words + * @return {boolean} whether to break word or not + */ + static canBreakChars(char, nextChar, token, index, breakWords) // eslint-disable-line no-unused-vars + { + return true; } /** @@ -325,3 +641,37 @@ * @private */ TextMetrics._fonts = {}; + +/** + * Cache of new line chars. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._newlines = [ + 0x000A, // line feed + 0x000D, // carriage return +]; + +/** + * Cache of breaking spaces. + * @memberof PIXI.TextMetrics + * @type {number[]} + * @private + */ +TextMetrics._breakingSpaces = [ + 0x0009, // character tabulation + 0x0020, // space + 0x2000, // en quad + 0x2001, // em quad + 0x2002, // en space + 0x2003, // em space + 0x2004, // three-per-em space + 0x2005, // four-per-em space + 0x2006, // six-per-em space + 0x2008, // punctuation space + 0x2009, // thin space + 0x200A, // hair space + 0x205F, // medium mathematical space + 0x3000, // ideographic space +]; diff --git a/packages/text/src/TextStyle.js b/packages/text/src/TextStyle.js index b37d69e..e7b7400 100644 --- a/packages/text/src/TextStyle.js +++ b/packages/text/src/TextStyle.js @@ -30,6 +30,7 @@ strokeThickness: 0, textBaseline: 'alphabetic', trim: false, + whiteSpace: 'pre', wordWrap: false, wordWrapWidth: 100, leading: 0, @@ -87,6 +88,8 @@ * Default is 0 (no stroke) * @param {boolean} [style.trim=false] - Trim transparent borders * @param {string} [style.textBaseline='alphabetic'] - The baseline of the text that is rendered. + * @param {boolean} [style.whiteSpace='pre'] - Determines whether newlines & spaces are collapsed or preserved "normal" + * (collapse, collapse), "pre" (preserve, preserve) | "pre-line" (preserve, collapse). It needs wordWrap to be set to true * @param {boolean} [style.wordWrap=false] - Indicates if word wrap should be used * @param {number} [style.wordWrapWidth=100] - The width at which text will wrap, it needs wordWrap to be set to true */ @@ -607,6 +610,31 @@ } /** + * How newlines and spaces should be handled. + * Default is 'pre' (preserve, preserve). + * + * value | New lines | Spaces + * --- | --- | --- + * 'normal' | Collapse | Collapse + * 'pre' | Preserve | Preserve + * 'pre-line' | Preserve | Collapse + * + * @member {string} + */ + get whiteSpace() + { + return this._whiteSpace; + } + set whiteSpace(whiteSpace) // eslint-disable-line require-jsdoc + { + if (this._whiteSpace !== whiteSpace) + { + this._whiteSpace = whiteSpace; + this.styleID++; + } + } + + /** * Indicates if word wrap should be used * * @member {boolean} diff --git a/packages/text/test/TextMetrics.js b/packages/text/test/TextMetrics.js new file mode 100644 index 0000000..c7bc29a --- /dev/null +++ b/packages/text/test/TextMetrics.js @@ -0,0 +1,610 @@ +const { TextMetrics, TextStyle } = require('../'); + +/* eslint-disable no-multi-str */ +const longText = 'Sed ut perspiciatis unde omnis iste natus error sit voluptatem \ +accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo \ +inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. Nemo \ +enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia \ +consequuntur magni dolores eos qui ratione voluptatem sequi nesciunt. Neque porro \ +quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, \ +sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam \ +quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam \ +corporis suscipit laboriosam, nisi ut aliquid ex ea commodi consequatur? Quis \ +autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil \ +molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla \ +pariatur?'; + +/* eslint-disable max-len */ +const spaceNewLineText = ' Should have\u0009space\u2003at the\u2000beginning of the line.\n And 3 more here. But after that there should be no\u3000more ridiculous spaces at the beginning of lines. And none at the end. And all this text is just to check the wrapping abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz. I \u2665 text. 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 '; +const breakingWordText = 'Pixi.js - The HTML5 Creation Engine. Create beautiful digital content with the supercalifragilisticexpialidociously fastest, most flexible 2D WebGL renderer.'; +const fillText = '. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . '; +const intergityText = '012345678901234567890123456789'; +const nonBreakingSpaces = ['\u00A0', '\u2007', '\u202F']; + +const breakingSpaces = [ + '\u0009', + '\u0020', + '\u2000', + '\u2001', + '\u2002', + '\u2003', + '\u2004', + '\u2005', + '\u2006', + '\u2008', + '\u2009', + '\u200A', + '\u205F', + '\u3000', +]; + +describe('PIXI.TextMetrics', function () +{ + const defaultStyle = { + breakWords: true, + fontFamily: 'Arial', + fontSize: 20, + fontStyle: 'italic', + fontVariant: 'normal', + fontWeight: 900, + wordWrap: true, + wordWrapWidth: 200, + letterSpacing: 4, + }; + + describe('wordWrap without breakWords', function () + { + it('width should not be greater than wordWrapWidth with longText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(longText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be greater than wordWrapWidth with breakingWordText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(breakingWordText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be within a character width from wordWrapWidth with fillText', function () + { + const charWidth = 4; // it should fill the line to at lease width -4 + + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(fillText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + expect(metrics.width + charWidth).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be greater than wordWrapWidth and should format correct spaces', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal(' ', '1st line should start with a space'); + expect(metrics.lines[4][0]).to.equal(' ', '5th line should start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal(' ', '5th line should start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal(' ', '5th line should start with 3 spaces (3)'); + expect(metrics.lines[4][3]).to.not.equal(' ', '5th line should not have a space as the 4th char'); + + metrics.lines.forEach((line, i) => + { + if (i !== 0 && i !== 4) + { + expect(metrics.lines[1][0]).to.not.equal(' ', 'all lines except 1 & 5 should not have space at the start'); + } + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('wordWrap with breakWords', function () + { + it('width should not be greater than wordWrapWidth with longText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(longText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should not be greater than wordWrapWidth with breakingWordAtStartText', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(breakingWordText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('width should be within a character width from wordWrapWidth with fillText', function () + { + const charWidth = 4; // it should fill the line to at lease width -4 + + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(fillText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + expect(metrics.width + charWidth).to.be.above(style.wordWrapWidth); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'should not have space at the end'); + }); + }); + + it('no words or characters should lost or changed', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(intergityText, new TextStyle(style)); + + const lines = metrics.lines.reduce((accumulator, line) => accumulator + line); + + expect(lines).to.equal(intergityText, 'should have the same chars as the original text'); + }); + + it('width should not be greater than wordWrapWidth and should format correct spaces', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal(' ', '1st line should start with a space'); + expect(metrics.lines[4][0]).to.equal(' ', '5th line should start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal(' ', '5th line should start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal(' ', '5th line should start with 3 spaces (3)'); + expect(metrics.lines[4][3]).to.not.equal(' ', '5th line should not have a space as the 4th char'); + + metrics.lines.forEach((line, i) => + { + if (i !== 0 && i !== 4) + { + expect(metrics.lines[1][0]).to.not.equal(' ', 'all lines except 1 & 5 should not have space at the start'); + } + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `normal` without breakWords', function () + { + it('multiple spaces should be collapsed to 1 and but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false, whiteSpace: 'normal' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('m', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('o', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('r', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('a', '17th line should not have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `pre-line` without breakWords', function () + { + it('multiple spaces should be collapsed to 1 but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: false, whiteSpace: 'pre-line' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.above(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('A', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('n', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('d', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('t', '17th line should have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `normal` with breakWords', function () + { + it('multiple spaces should be collapsed to 1 and but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true, whiteSpace: 'normal' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('m', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('o', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('r', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('a', '17th line should not have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('whiteSpace `pre-line` with breakWords', function () + { + it('multiple spaces should be collapsed to 1 but not newlines', function () + { + const style = Object.assign({}, defaultStyle, { breakWords: true, whiteSpace: 'pre-line' }); + + const metrics = TextMetrics.measureText(spaceNewLineText, new TextStyle(style)); + + expect(metrics.width).to.be.below(style.wordWrapWidth); + + expect(metrics.lines[0][0]).to.equal('S', '1st line should not start with a space'); + expect(metrics.lines[4][0]).to.equal('A', '5th line should not start with 3 spaces (1)'); + expect(metrics.lines[4][1]).to.equal('n', '5th line should not start with 3 spaces (2)'); + expect(metrics.lines[4][2]).to.equal('d', '5th line should not start with 3 spaces (3)'); + expect(metrics.lines[17][0]).to.equal('t', '17th line should have wrapped'); + + metrics.lines.forEach((line) => + { + expect(line[0]).to.not.equal(' ', 'all lines should not have space at the start'); + expect(line[line - 1]).to.not.equal(' ', 'no lines should have a space at the end'); + }); + }); + }); + + describe('trimRight', function () + { + it('string with no whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right'); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('string with whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right '); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('string with strange unicode whitespaces to trim', function () + { + const text = TextMetrics.trimRight('remove white spaces to the right\u0009\u0020\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2008\u2009\u200A\u205F\u3000'); + + expect(text).to.equal('remove white spaces to the right'); + }); + + it('empty string', function () + { + const text = TextMetrics.trimRight(''); + + expect(text).to.equal(''); + }); + + it('non-string input', function () + { + const text = TextMetrics.trimRight({}); + + expect(text).to.equal(''); + }); + }); + + describe('isNewline', function () + { + it('line feed', function () + { + const bool = TextMetrics.isNewline('\n'); + + expect(bool).to.equal(true); + }); + + it('carriage return', function () + { + const bool = TextMetrics.isNewline('\r'); + + expect(bool).to.equal(true); + }); + + it('newline char', function () + { + const bool = TextMetrics.isNewline('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.isNewline({}); + + expect(bool).to.equal(false); + }); + }); + + describe('isBreakingSpace', function () + { + it('legit breaking spaces', function () + { + breakingSpaces.forEach((char) => + { + const bool = TextMetrics.isBreakingSpace(char); + + expect(bool).to.equal(true); + }); + }); + + it('non breaking spaces', function () + { + nonBreakingSpaces.forEach((char) => + { + const bool = TextMetrics.isBreakingSpace(char); + + expect(bool).to.not.equal(true); + }); + }); + + it('newline char', function () + { + const bool = TextMetrics.isBreakingSpace('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.isBreakingSpace({}); + + expect(bool).to.equal(false); + }); + }); + + describe('tokenize', function () + { + it('full example', function () + { + const arr = TextMetrics.tokenize(spaceNewLineText); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(146); + expect(arr).to.not.contain(''); + expect(arr).to.not.contain(null); + }); + + it('empty string', function () + { + const arr = TextMetrics.tokenize(''); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + + it('single char', function () + { + const arr = TextMetrics.tokenize('A'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('newline char', function () + { + const arr = TextMetrics.tokenize('\n'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('breakingSpaces', function () + { + const arr = TextMetrics.tokenize(breakingSpaces.join('')); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(breakingSpaces.length); + }); + + it('non string', function () + { + const arr = TextMetrics.tokenize({}); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + }); + + describe('collapseSpaces', function () + { + it('pre', function () + { + const bool = TextMetrics.collapseSpaces('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = TextMetrics.collapseSpaces('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = TextMetrics.collapseSpaces('pre-line'); + + expect(bool).to.equal(true); + }); + + it('non matching string', function () + { + const bool = TextMetrics.collapseSpaces('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.collapseSpaces({}); + + expect(bool).to.equal(false); + }); + }); + + describe('collapseNewlines', function () + { + it('pre', function () + { + const bool = TextMetrics.collapseNewlines('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = TextMetrics.collapseNewlines('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = TextMetrics.collapseNewlines('pre-line'); + + expect(bool).to.equal(false); + }); + + it('non matching string', function () + { + const bool = TextMetrics.collapseNewlines('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = TextMetrics.collapseNewlines({}); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakWords', function () + { + it('breakWords: true', function () + { + const bool = TextMetrics.canBreakWords('text', true); + + expect(bool).to.equal(true); + }); + + it('breakWords: false', function () + { + const bool = TextMetrics.canBreakWords('text', false); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakChars', function () + { + it('should always return true', function () + { + const bool = TextMetrics.canBreakChars(); + + expect(bool).to.equal(true); + }); + + it('should prevent breaking for all numbers', function () + { + const style = new TextStyle({ + fontFamily: 'Arial', + fontSize: 26, + fontStyle: 'italic', + fontVariant: 'normal', + fontWeight: 900, + wordWrap: true, + wordWrapWidth: 300, + letterSpacing: 4, + padding: 10, + fill: 0xffffff, + breakWords: false, + whiteSpace: 'pre-line', + }); + + const str = '-------0000,1111,9999------'; + const reg = /^\d+$/; + + TextMetrics.canBreakWords = function () + { + return true; + }; + + // override breakChars + TextMetrics.canBreakChars = function (char, nextChar) + { + return !(char.match(reg) && nextChar.match(reg)); + }; + + const metrics = TextMetrics.measureText(str, style); + + expect(metrics.lines[0]).to.equal('-------0000,1111,'); + expect(metrics.lines[1]).to.equal('9999------'); + }); + }); +}); diff --git a/packages/text/test/index.js b/packages/text/test/index.js index 507af20..b575c72 100644 --- a/packages/text/test/index.js +++ b/packages/text/test/index.js @@ -1,2 +1,3 @@ require('./Text'); require('./TextStyle'); +require('./TextMetrics');