/** * 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 */ export default class TextMetrics { /** * @param {string} text - the text that was measured * @param {PIXI.TextStyle} style - the style that was measured * @param {number} width - the measured width of the text * @param {number} height - the measured height of the text * @param {array} lines - an array of the lines of text broken by new lines and wrapping if specified in style * @param {array} lineWidths - an array of the line widths for each line matched to `lines` * @param {number} lineHeight - the measured line height for this style * @param {number} maxLineWidth - the maximum line width for all measured lines * @param {Object} fontProperties - the font properties object from TextMetrics.measureFont */ constructor(text, style, width, height, lines, lineWidths, lineHeight, maxLineWidth, fontProperties) { this.text = text; this.style = style; this.width = width; this.height = height; this.lines = lines; this.lineWidths = lineWidths; this.lineHeight = lineHeight; this.maxLineWidth = maxLineWidth; this.fontProperties = fontProperties; } /** * Measures the supplied string of text and returns a Rectangle. * * @param {string} text - the text to measure. * @param {PIXI.TextStyle} style - the text style to use for measuring * @param {boolean} [wordWrap] - optional override for if word-wrap should be applied to the text. * @param {HTMLCanvasElement} [canvas] - optional specification of the canvas to use for measuring. * @return {PIXI.TextMetrics} measured width and height of the text. */ static measureText(text, style, wordWrap, canvas = TextMetrics._canvas) { wordWrap = wordWrap || style.wordWrap; const font = style.toFontString(); const fontProperties = TextMetrics.measureFont(font); const context = canvas.getContext('2d'); context.font = font; const outputText = wordWrap ? TextMetrics.wordWrap(text, style, canvas) : text; const lines = outputText.split(/(?:\r\n|\r|\n)/); const lineWidths = new Array(lines.length); let maxLineWidth = 0; for (let i = 0; i < lines.length; i++) { const lineWidth = context.measureText(lines[i]).width + ((lines[i].length - 1) * style.letterSpacing); lineWidths[i] = lineWidth; maxLineWidth = Math.max(maxLineWidth, lineWidth); } let width = maxLineWidth + style.strokeThickness; if (style.dropShadow) { width += style.dropShadowDistance; } const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) + ((lines.length - 1) * (lineHeight + style.leading)); if (style.dropShadow) { height += style.dropShadowDistance; } return new TextMetrics( text, style, width, height, lines, lineWidths, lineHeight + style.leading, maxLineWidth, fontProperties ); } /** * Applies newlines to a string to have it optimally fit into the horizontal * bounds set by the Text object's wordWrapWidth property. * * @private * @param {string} text - String to apply word wrapping to * @param {PIXI.TextStyle} style - the style to use when wrapping * @param {HTMLCanvasElement} [canvas] - optional specification of the canvas to use for measuring. * @return {string} New string with new lines applied where required */ static wordWrap(text, style, canvas = TextMetrics._canvas) { const context = canvas.getContext('2d'); let line = ''; let width = 0; let lines = ''; const cache = {}; const ls = style.letterSpacing; // ideally 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 // 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; // get the width of a space and add it to cache const spaceWidth = TextMetrics.getFromCache(' ', ls, cache, context); // break text into words const words = text.split(' '); for (let i = 0; i < words.length; i++) { const word = words[i]; // get word width from cache if possible const wordWidth = TextMetrics.getFromCache(word, ls, cache, context); // word is longer than desired bounds if (wordWidth > wordWrapWidth) { // break large word over multiple lines if (style.breakWords) { // add a space to the start of the word unless its at the beginning of the line const tmpWord = (line.length > 0) ? ` ${word}` : word; // break word into characters const characters = tmpWord.split(''); // loop the characters for (let j = 0; j < characters.length; j++) { const character = characters[j]; const characterWidth = TextMetrics.getFromCache(character, ls, cache, context); if (characterWidth + width > wordWrapWidth) { lines += TextMetrics.addLine(line); line = ''; width = 0; } line += character; 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(word); line = ''; width = 0; } } // word could fit else { // word won't fit, start a new line if (wordWidth + width > wordWrapWidth) { lines += TextMetrics.addLine(line); line = ''; width = 0; } // add the word to the current line if (line.length > 0) { // add a space if it is not the beginning line += ` ${word}`; } else { // add without a space if it is the beginning line += word; } width += wordWidth + spaceWidth; } } lines += TextMetrics.addLine(line, false); return lines; } /** * 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 * @return {string} A formatted line */ static addLine(line, newLine = true) { line = (newLine) ? `${line}\n` : line; return line; } /** * Gets & sets the widths of calculated characters in a cache object * * @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; } /** * Calculates the ascent, descent and fontSize of a given font-style * * @static * @param {string} font - String representing the style of the font * @return {PIXI.TextMetrics~FontMetrics} Font properties object */ static measureFont(font) { // as this method is used for preparing assets, don't recalculate things if we don't need to if (TextMetrics._fonts[font]) { return TextMetrics._fonts[font]; } const properties = {}; const canvas = TextMetrics._canvas; const context = TextMetrics._context; context.font = font; const width = Math.ceil(context.measureText('|MÉq').width); let baseline = Math.ceil(context.measureText('M').width); const height = 2 * baseline; baseline = baseline * 1.4 | 0; canvas.width = width; canvas.height = height; context.fillStyle = '#f00'; context.fillRect(0, 0, width, height); context.font = font; context.textBaseline = 'alphabetic'; context.fillStyle = '#000'; context.fillText('|MÉq', 0, baseline); const imagedata = context.getImageData(0, 0, width, height).data; const pixels = imagedata.length; const line = width * 4; let i = 0; let idx = 0; let stop = false; // ascent. scan from top to bottom until we find a non red pixel for (i = 0; i < baseline; ++i) { for (let j = 0; j < line; j += 4) { if (imagedata[idx + j] !== 255) { stop = true; break; } } if (!stop) { idx += line; } else { break; } } properties.ascent = baseline - i; idx = pixels - line; stop = false; // descent. scan from bottom to top until we find a non red pixel for (i = height; i > baseline; --i) { for (let j = 0; j < line; j += 4) { if (imagedata[idx + j] !== 255) { stop = true; break; } } if (!stop) { idx -= line; } else { break; } } properties.descent = i - baseline; properties.fontSize = properties.ascent + properties.descent; TextMetrics._fonts[font] = properties; return properties; } } /** * Internal return object for {@link PIXI.TextMetrics.measureFont `TextMetrics.measureFont`}. * @class FontMetrics * @memberof PIXI.TextMetrics~ * @property {number} ascent - The ascent distance * @property {number} descent - The descent distance * @property {number} fontSize - Font size from ascent to descent */ const canvas = document.createElement('canvas'); canvas.width = canvas.height = 10; /** * Cached canvas element for measuring text * @memberof PIXI.TextMetrics * @type {HTMLCanvasElement} * @private */ TextMetrics._canvas = canvas; /** * Cache for context to use. * @memberof PIXI.TextMetrics * @type {CanvasRenderingContext2D} * @private */ TextMetrics._context = canvas.getContext('2d'); /** * Cache of PIXI.TextMetrics~FontMetrics objects. * @memberof PIXI.TextMetrics * @type {Object} * @private */ TextMetrics._fonts = {};