diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js index 99748f5..4fedc56 100644 --- a/src/core/text/TextMetrics.js +++ b/src/core/text/TextMetrics.js @@ -108,59 +108,128 @@ { const context = canvas.getContext('2d'); - let line = ''; let width = 0; + let line = ''; let lines = ''; - const cache = {}; - const ls = style.letterSpacing; - // ideally there is letterSpacing after every char except the last one + 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 space + // 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 + style.letterSpacing; + const wordWrapWidth = style.wordWrapWidth + letterSpacing; - // get the width of a space and add it to cache - const spaceWidth = TextMetrics.getFromCache(' ', ls, cache, context); + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); - // break text into words - const words = text.split(' '); - - for (let i = 0; i < words.length; i++) + for (let i = 0; i < tokens.length; i++) { - const word = words[i]; + // get the word, space or newlineChar + let token = tokens[i]; + + // if word is a new line + if (TextMetrics.isNewline(token)) + { + // keep the new line + if (!collapseNewlines) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; + } + + // 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; + } + } // get word width from cache if possible - const wordWidth = TextMetrics.getFromCache(word, ls, cache, context); + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); // word is longer than desired bounds - if (wordWidth > wordWrapWidth) + if (tokenWidth > wordWrapWidth) { - // break large word over multiple lines - if (style.breakWords) + // if we are not already at the beginning of a line + if (line !== '') { - // add a space to the start of the word unless its at the beginning of the line - const tmpWord = (line.length > 0) ? ` ${word}` : word; + // 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 = tmpWord.split(''); + const characters = token.split(''); // loop the characters for (let j = 0; j < characters.length; j++) { - const character = characters[j]; - const characterWidth = TextMetrics.getFromCache(character, ls, cache, context); + 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 += character; + line += char; width += characterWidth; } } @@ -178,7 +247,8 @@ } // give it its own line - lines += TextMetrics.addLine(word); + lines += TextMetrics.addLine(token); + canPrependSpaces = false; line = ''; width = 0; } @@ -187,27 +257,30 @@ // word could fit else { - // word won't fit, start a new line - if (wordWidth + width > wordWrapWidth) + // 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; } - // add the word to the current line - if (line.length > 0) + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) { - // add a space if it is not the beginning - line += ` ${word}`; - } - else - { - // add without a space if it is the beginning - line += word; - } + // add the word to the current line + line += token; - width += wordWidth + spaceWidth; + // update width counter + width += tokenWidth; + } } } @@ -217,15 +290,18 @@ } /** - * Convienience function for logging each line added - * during the wordWrap method + * Convienience function for logging each line added during the wordWrap + * method * - * @param {string} line - The line of text to add - * @param {boolean} newLine - Add new line character to end + * @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; @@ -234,6 +310,7 @@ /** * 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 @@ -256,6 +333,174 @@ } /** + * 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; + } + + /** * Calculates the ascent, descent and fontSize of a given font-style * * @static @@ -396,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/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js index 99748f5..4fedc56 100644 --- a/src/core/text/TextMetrics.js +++ b/src/core/text/TextMetrics.js @@ -108,59 +108,128 @@ { const context = canvas.getContext('2d'); - let line = ''; let width = 0; + let line = ''; let lines = ''; - const cache = {}; - const ls = style.letterSpacing; - // ideally there is letterSpacing after every char except the last one + 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 space + // 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 + style.letterSpacing; + const wordWrapWidth = style.wordWrapWidth + letterSpacing; - // get the width of a space and add it to cache - const spaceWidth = TextMetrics.getFromCache(' ', ls, cache, context); + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); - // break text into words - const words = text.split(' '); - - for (let i = 0; i < words.length; i++) + for (let i = 0; i < tokens.length; i++) { - const word = words[i]; + // get the word, space or newlineChar + let token = tokens[i]; + + // if word is a new line + if (TextMetrics.isNewline(token)) + { + // keep the new line + if (!collapseNewlines) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; + } + + // 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; + } + } // get word width from cache if possible - const wordWidth = TextMetrics.getFromCache(word, ls, cache, context); + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); // word is longer than desired bounds - if (wordWidth > wordWrapWidth) + if (tokenWidth > wordWrapWidth) { - // break large word over multiple lines - if (style.breakWords) + // if we are not already at the beginning of a line + if (line !== '') { - // add a space to the start of the word unless its at the beginning of the line - const tmpWord = (line.length > 0) ? ` ${word}` : word; + // 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 = tmpWord.split(''); + const characters = token.split(''); // loop the characters for (let j = 0; j < characters.length; j++) { - const character = characters[j]; - const characterWidth = TextMetrics.getFromCache(character, ls, cache, context); + 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 += character; + line += char; width += characterWidth; } } @@ -178,7 +247,8 @@ } // give it its own line - lines += TextMetrics.addLine(word); + lines += TextMetrics.addLine(token); + canPrependSpaces = false; line = ''; width = 0; } @@ -187,27 +257,30 @@ // word could fit else { - // word won't fit, start a new line - if (wordWidth + width > wordWrapWidth) + // 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; } - // add the word to the current line - if (line.length > 0) + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) { - // add a space if it is not the beginning - line += ` ${word}`; - } - else - { - // add without a space if it is the beginning - line += word; - } + // add the word to the current line + line += token; - width += wordWidth + spaceWidth; + // update width counter + width += tokenWidth; + } } } @@ -217,15 +290,18 @@ } /** - * Convienience function for logging each line added - * during the wordWrap method + * Convienience function for logging each line added during the wordWrap + * method * - * @param {string} line - The line of text to add - * @param {boolean} newLine - Add new line character to end + * @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; @@ -234,6 +310,7 @@ /** * 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 @@ -256,6 +333,174 @@ } /** + * 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; + } + + /** * Calculates the ascent, descent and fontSize of a given font-style * * @static @@ -396,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/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 6b1b00d..84463b4 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/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/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js index 99748f5..4fedc56 100644 --- a/src/core/text/TextMetrics.js +++ b/src/core/text/TextMetrics.js @@ -108,59 +108,128 @@ { const context = canvas.getContext('2d'); - let line = ''; let width = 0; + let line = ''; let lines = ''; - const cache = {}; - const ls = style.letterSpacing; - // ideally there is letterSpacing after every char except the last one + 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 space + // 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 + style.letterSpacing; + const wordWrapWidth = style.wordWrapWidth + letterSpacing; - // get the width of a space and add it to cache - const spaceWidth = TextMetrics.getFromCache(' ', ls, cache, context); + // break text into words, spaces and newline chars + const tokens = TextMetrics.tokenize(text); - // break text into words - const words = text.split(' '); - - for (let i = 0; i < words.length; i++) + for (let i = 0; i < tokens.length; i++) { - const word = words[i]; + // get the word, space or newlineChar + let token = tokens[i]; + + // if word is a new line + if (TextMetrics.isNewline(token)) + { + // keep the new line + if (!collapseNewlines) + { + lines += TextMetrics.addLine(line); + canPrependSpaces = !collapseSpaces; + line = ''; + width = 0; + continue; + } + + // 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; + } + } // get word width from cache if possible - const wordWidth = TextMetrics.getFromCache(word, ls, cache, context); + const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context); // word is longer than desired bounds - if (wordWidth > wordWrapWidth) + if (tokenWidth > wordWrapWidth) { - // break large word over multiple lines - if (style.breakWords) + // if we are not already at the beginning of a line + if (line !== '') { - // add a space to the start of the word unless its at the beginning of the line - const tmpWord = (line.length > 0) ? ` ${word}` : word; + // 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 = tmpWord.split(''); + const characters = token.split(''); // loop the characters for (let j = 0; j < characters.length; j++) { - const character = characters[j]; - const characterWidth = TextMetrics.getFromCache(character, ls, cache, context); + 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 += character; + line += char; width += characterWidth; } } @@ -178,7 +247,8 @@ } // give it its own line - lines += TextMetrics.addLine(word); + lines += TextMetrics.addLine(token); + canPrependSpaces = false; line = ''; width = 0; } @@ -187,27 +257,30 @@ // word could fit else { - // word won't fit, start a new line - if (wordWidth + width > wordWrapWidth) + // 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; } - // add the word to the current line - if (line.length > 0) + // don't add spaces to the beginning of lines + if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) { - // add a space if it is not the beginning - line += ` ${word}`; - } - else - { - // add without a space if it is the beginning - line += word; - } + // add the word to the current line + line += token; - width += wordWidth + spaceWidth; + // update width counter + width += tokenWidth; + } } } @@ -217,15 +290,18 @@ } /** - * Convienience function for logging each line added - * during the wordWrap method + * Convienience function for logging each line added during the wordWrap + * method * - * @param {string} line - The line of text to add - * @param {boolean} newLine - Add new line character to end + * @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; @@ -234,6 +310,7 @@ /** * 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 @@ -256,6 +333,174 @@ } /** + * 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; + } + + /** * Calculates the ascent, descent and fontSize of a given font-style * * @static @@ -396,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/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 6b1b00d..84463b4 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/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/test/core/TextMetrics.js b/test/core/TextMetrics.js index 4ed7e27..0a2d7f5 100644 --- a/test/core/TextMetrics.js +++ b/test/core/TextMetrics.js @@ -14,14 +14,29 @@ molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla \ pariatur?'; -const breakingWordText = 'Pixi.js - The HTML5 Creation Engine. Create beautiful \ -digital content with the supercalifragilisticexpialidociously fastest, most \ -flexible 2D WebGL renderer.'; - -const fillText = '. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .\ -. . . . . . . . . . . . . . . . . . . . . . . . '; - +/* 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 () { @@ -86,6 +101,30 @@ 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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 () @@ -148,5 +187,424 @@ 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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 = PIXI.TextMetrics.measureText(spaceNewLineText, new PIXI.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 = PIXI.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 = PIXI.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 = PIXI.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 = PIXI.TextMetrics.trimRight(''); + + expect(text).to.equal(''); + }); + + it('non-string input', function () + { + const text = PIXI.TextMetrics.trimRight({}); + + expect(text).to.equal(''); + }); + }); + + describe('isNewline', function () + { + it('line feed', function () + { + const bool = PIXI.TextMetrics.isNewline('\n'); + + expect(bool).to.equal(true); + }); + + it('carriage return', function () + { + const bool = PIXI.TextMetrics.isNewline('\r'); + + expect(bool).to.equal(true); + }); + + it('newline char', function () + { + const bool = PIXI.TextMetrics.isNewline('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = PIXI.TextMetrics.isNewline({}); + + expect(bool).to.equal(false); + }); + }); + + describe('isBreakingSpace', function () + { + it('legit breaking spaces', function () + { + breakingSpaces.forEach((char) => + { + const bool = PIXI.TextMetrics.isBreakingSpace(char); + + expect(bool).to.equal(true); + }); + }); + + it('non breaking spaces', function () + { + nonBreakingSpaces.forEach((char) => + { + const bool = PIXI.TextMetrics.isBreakingSpace(char); + + expect(bool).to.not.equal(true); + }); + }); + + it('newline char', function () + { + const bool = PIXI.TextMetrics.isBreakingSpace('A'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = PIXI.TextMetrics.isBreakingSpace({}); + + expect(bool).to.equal(false); + }); + }); + + describe('tokenize', function () + { + it('full example', function () + { + const arr = PIXI.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 = PIXI.TextMetrics.tokenize(''); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + + it('single char', function () + { + const arr = PIXI.TextMetrics.tokenize('A'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('newline char', function () + { + const arr = PIXI.TextMetrics.tokenize('\n'); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(1); + }); + + it('breakingSpaces', function () + { + const arr = PIXI.TextMetrics.tokenize(breakingSpaces.join('')); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(breakingSpaces.length); + }); + + it('non string', function () + { + const arr = PIXI.TextMetrics.tokenize({}); + + expect(arr).to.be.an('array'); + expect(arr.length).to.equal(0); + }); + }); + + describe('collapseSpaces', function () + { + it('pre', function () + { + const bool = PIXI.TextMetrics.collapseSpaces('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = PIXI.TextMetrics.collapseSpaces('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = PIXI.TextMetrics.collapseSpaces('pre-line'); + + expect(bool).to.equal(true); + }); + + it('non matching string', function () + { + const bool = PIXI.TextMetrics.collapseSpaces('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = PIXI.TextMetrics.collapseSpaces({}); + + expect(bool).to.equal(false); + }); + }); + + describe('collapseNewlines', function () + { + it('pre', function () + { + const bool = PIXI.TextMetrics.collapseNewlines('pre'); + + expect(bool).to.equal(false); + }); + + it('normal', function () + { + const bool = PIXI.TextMetrics.collapseNewlines('normal'); + + expect(bool).to.equal(true); + }); + + it('pre-line', function () + { + const bool = PIXI.TextMetrics.collapseNewlines('pre-line'); + + expect(bool).to.equal(false); + }); + + it('non matching string', function () + { + const bool = PIXI.TextMetrics.collapseNewlines('bull'); + + expect(bool).to.equal(false); + }); + + it('non string', function () + { + const bool = PIXI.TextMetrics.collapseNewlines({}); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakWords', function () + { + it('breakWords: true', function () + { + const bool = PIXI.TextMetrics.canBreakWords('text', true); + + expect(bool).to.equal(true); + }); + + it('breakWords: false', function () + { + const bool = PIXI.TextMetrics.canBreakWords('text', false); + + expect(bool).to.equal(false); + }); + }); + + describe('canBreakChars', function () + { + it('should always return true', function () + { + const bool = PIXI.TextMetrics.canBreakChars(); + + expect(bool).to.equal(true); + }); + + it('should prevent breaking for all numbers', function () + { + const style = new PIXI.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+$/; + + PIXI.TextMetrics.canBreakWords = function () + { + return true; + }; + + // override breakChars + PIXI.TextMetrics.canBreakChars = function (char, nextChar) + { + return !(char.match(reg) && nextChar.match(reg)); + }; + + const metrics = PIXI.TextMetrics.measureText(str, style); + + expect(metrics.lines[0]).to.equal('-------0000,1111,'); + expect(metrics.lines[1]).to.equal('9999------'); + }); }); });