diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js new file mode 100644 index 0000000..27bd145 --- /dev/null +++ b/src/core/text/TextMetrics.js @@ -0,0 +1,327 @@ +/** + * The TextMetrics object represents the measurement of a block of text with a specified 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); + + if (style.dropShadow) + { + height += style.dropShadowDistance; + } + + return new TextMetrics( + text, + style, + width, + height, + lines, + lineWidths, + lineHeight, + 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'); + + // 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 = {}; + + for (let i = 0; i < lines.length; i++) + { + let spaceLeft = wordWrapWidth; + const words = lines[i].split(' '); + + for (let j = 0; j < words.length; j++) + { + const wordWidth = context.measureText(words[j]).width; + + if (style.breakWords && wordWidth > wordWrapWidth) + { + // 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; + } + } + } + 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 (i < lines.length - 1) + { + result += '\n'; + } + } + + return result; + } + + /** + * 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 = {}; diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js new file mode 100644 index 0000000..27bd145 --- /dev/null +++ b/src/core/text/TextMetrics.js @@ -0,0 +1,327 @@ +/** + * The TextMetrics object represents the measurement of a block of text with a specified 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); + + if (style.dropShadow) + { + height += style.dropShadowDistance; + } + + return new TextMetrics( + text, + style, + width, + height, + lines, + lineWidths, + lineHeight, + 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'); + + // 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 = {}; + + for (let i = 0; i < lines.length; i++) + { + let spaceLeft = wordWrapWidth; + const words = lines[i].split(' '); + + for (let j = 0; j < words.length; j++) + { + const wordWidth = context.measureText(words[j]).width; + + if (style.breakWords && wordWidth > wordWrapWidth) + { + // 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; + } + } + } + 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 (i < lines.length - 1) + { + result += '\n'; + } + } + + return result; + } + + /** + * 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 = {}; diff --git a/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 890950b..4d66953 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/TextStyle.js @@ -473,6 +473,41 @@ this.styleID++; } } + + /** + * Generates a font style string to use for `TextMetrics.measureFont()`. + * + * @return {string} Font style string, for passing to `TextMetrics.measureFont()` + */ + toFontString() + { + // build canvas api font setting from individual components. Convert a numeric this.fontSize to px + const fontSizeString = (typeof this.fontSize === 'number') ? `${this.fontSize}px` : this.fontSize; + + // Clean-up fontFamily property by quoting each font name + // this will support font names with spaces + let fontFamilies = this.fontFamily; + + if (!Array.isArray(this.fontFamily)) + { + fontFamilies = this.fontFamily.split(','); + } + + for (let i = fontFamilies.length - 1; i >= 0; i--) + { + // Trim any extra white-space + let fontFamily = fontFamilies[i].trim(); + + // Check if font already contains strings + if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) + { + fontFamily = `"${fontFamily}"`; + } + fontFamilies[i] = fontFamily; + } + + return `${this.fontStyle} ${this.fontVariant} ${this.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; + } } /** diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js new file mode 100644 index 0000000..27bd145 --- /dev/null +++ b/src/core/text/TextMetrics.js @@ -0,0 +1,327 @@ +/** + * The TextMetrics object represents the measurement of a block of text with a specified 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); + + if (style.dropShadow) + { + height += style.dropShadowDistance; + } + + return new TextMetrics( + text, + style, + width, + height, + lines, + lineWidths, + lineHeight, + 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'); + + // 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 = {}; + + for (let i = 0; i < lines.length; i++) + { + let spaceLeft = wordWrapWidth; + const words = lines[i].split(' '); + + for (let j = 0; j < words.length; j++) + { + const wordWidth = context.measureText(words[j]).width; + + if (style.breakWords && wordWidth > wordWrapWidth) + { + // 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; + } + } + } + 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 (i < lines.length - 1) + { + result += '\n'; + } + } + + return result; + } + + /** + * 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 = {}; diff --git a/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 890950b..4d66953 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/TextStyle.js @@ -473,6 +473,41 @@ this.styleID++; } } + + /** + * Generates a font style string to use for `TextMetrics.measureFont()`. + * + * @return {string} Font style string, for passing to `TextMetrics.measureFont()` + */ + toFontString() + { + // build canvas api font setting from individual components. Convert a numeric this.fontSize to px + const fontSizeString = (typeof this.fontSize === 'number') ? `${this.fontSize}px` : this.fontSize; + + // Clean-up fontFamily property by quoting each font name + // this will support font names with spaces + let fontFamilies = this.fontFamily; + + if (!Array.isArray(this.fontFamily)) + { + fontFamilies = this.fontFamily.split(','); + } + + for (let i = fontFamilies.length - 1; i >= 0; i--) + { + // Trim any extra white-space + let fontFamily = fontFamilies[i].trim(); + + // Check if font already contains strings + if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) + { + fontFamily = `"${fontFamily}"`; + } + fontFamilies[i] = fontFamily; + } + + return `${this.fontStyle} ${this.fontVariant} ${this.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; + } } /** diff --git a/src/deprecation.js b/src/deprecation.js index e7fe5ce..0a9a771 100644 --- a/src/deprecation.js +++ b/src/deprecation.js @@ -695,6 +695,49 @@ }; /** + * Calculates the ascent, descent and fontSize of a given fontStyle + * + * @name PIXI.Text.calculateFontProperties + * @see PIXI.TextMetrics.measureFont + * @deprecated since version 4.5.0 + * @param {string} font - String representing the style of the font + * @return {Object} Font properties object + */ +core.Text.calculateFontProperties = function calculateFontProperties(font) +{ + warn(`Text.calculateFontProperties is now deprecated, please use the TextMetrics.measureFont`); + + return core.TextMetrics.measureFont(font); +}; + +Object.defineProperties(core.Text, { + fontPropertiesCache: { + get() + { + warn(`Text.fontPropertiesCache is deprecated`); + + return core.TextMetrics._fonts; + }, + }, + fontPropertiesCanvas: { + get() + { + warn(`Text.fontPropertiesCanvas is deprecated`); + + return core.TextMetrics._canvas; + }, + }, + fontPropertiesContext: { + get() + { + warn(`Text.fontPropertiesContext is deprecated`); + + return core.TextMetrics._context; + }, + }, +}); + +/** * @method * @name PIXI.Text#setStyle * @see PIXI.Text#style @@ -710,7 +753,7 @@ /** * @method * @name PIXI.Text#determineFontProperties - * @see PIXI.Text#calculateFontProperties + * @see PIXI.Text#measureFontProperties * @deprecated since version 4.2.0 * @private * @param {string} fontStyle - String representing the style of the font @@ -718,10 +761,31 @@ */ core.Text.prototype.determineFontProperties = function determineFontProperties(fontStyle) { - warn('determineFontProperties is now deprecated, please use the static calculateFontProperties method, ' - + 'e.g : Text.calculateFontProperties(fontStyle);'); + warn('determineFontProperties is now deprecated, please use TextMetrics.measureFont method'); - return core.Text.calculateFontProperties(fontStyle); + return core.TextMetrics.measureFont(fontStyle); +}; + +/** + * @method + * @name PIXI.Text.getFontStyle + * @see PIXI.TextMetrics.getFontStyle + * @deprecated since version 4.5.0 + * @param {PIXI.TextStyle} style - The style to use. + * @return {string} Font string + */ +core.Text.getFontStyle = function getFontStyle(style) +{ + warn('getFontStyle is now deprecated, please use TextStyle.toFontString() instead'); + + style = style || {}; + + if (!(style instanceof core.TextStyle)) + { + style = new core.TextStyle(style); + } + + return style.toFontString(); }; Object.defineProperties(core.TextStyle.prototype, { diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js new file mode 100644 index 0000000..27bd145 --- /dev/null +++ b/src/core/text/TextMetrics.js @@ -0,0 +1,327 @@ +/** + * The TextMetrics object represents the measurement of a block of text with a specified 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); + + if (style.dropShadow) + { + height += style.dropShadowDistance; + } + + return new TextMetrics( + text, + style, + width, + height, + lines, + lineWidths, + lineHeight, + 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'); + + // 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 = {}; + + for (let i = 0; i < lines.length; i++) + { + let spaceLeft = wordWrapWidth; + const words = lines[i].split(' '); + + for (let j = 0; j < words.length; j++) + { + const wordWidth = context.measureText(words[j]).width; + + if (style.breakWords && wordWidth > wordWrapWidth) + { + // 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; + } + } + } + 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 (i < lines.length - 1) + { + result += '\n'; + } + } + + return result; + } + + /** + * 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 = {}; diff --git a/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 890950b..4d66953 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/TextStyle.js @@ -473,6 +473,41 @@ this.styleID++; } } + + /** + * Generates a font style string to use for `TextMetrics.measureFont()`. + * + * @return {string} Font style string, for passing to `TextMetrics.measureFont()` + */ + toFontString() + { + // build canvas api font setting from individual components. Convert a numeric this.fontSize to px + const fontSizeString = (typeof this.fontSize === 'number') ? `${this.fontSize}px` : this.fontSize; + + // Clean-up fontFamily property by quoting each font name + // this will support font names with spaces + let fontFamilies = this.fontFamily; + + if (!Array.isArray(this.fontFamily)) + { + fontFamilies = this.fontFamily.split(','); + } + + for (let i = fontFamilies.length - 1; i >= 0; i--) + { + // Trim any extra white-space + let fontFamily = fontFamilies[i].trim(); + + // Check if font already contains strings + if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) + { + fontFamily = `"${fontFamily}"`; + } + fontFamilies[i] = fontFamily; + } + + return `${this.fontStyle} ${this.fontVariant} ${this.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; + } } /** diff --git a/src/deprecation.js b/src/deprecation.js index e7fe5ce..0a9a771 100644 --- a/src/deprecation.js +++ b/src/deprecation.js @@ -695,6 +695,49 @@ }; /** + * Calculates the ascent, descent and fontSize of a given fontStyle + * + * @name PIXI.Text.calculateFontProperties + * @see PIXI.TextMetrics.measureFont + * @deprecated since version 4.5.0 + * @param {string} font - String representing the style of the font + * @return {Object} Font properties object + */ +core.Text.calculateFontProperties = function calculateFontProperties(font) +{ + warn(`Text.calculateFontProperties is now deprecated, please use the TextMetrics.measureFont`); + + return core.TextMetrics.measureFont(font); +}; + +Object.defineProperties(core.Text, { + fontPropertiesCache: { + get() + { + warn(`Text.fontPropertiesCache is deprecated`); + + return core.TextMetrics._fonts; + }, + }, + fontPropertiesCanvas: { + get() + { + warn(`Text.fontPropertiesCanvas is deprecated`); + + return core.TextMetrics._canvas; + }, + }, + fontPropertiesContext: { + get() + { + warn(`Text.fontPropertiesContext is deprecated`); + + return core.TextMetrics._context; + }, + }, +}); + +/** * @method * @name PIXI.Text#setStyle * @see PIXI.Text#style @@ -710,7 +753,7 @@ /** * @method * @name PIXI.Text#determineFontProperties - * @see PIXI.Text#calculateFontProperties + * @see PIXI.Text#measureFontProperties * @deprecated since version 4.2.0 * @private * @param {string} fontStyle - String representing the style of the font @@ -718,10 +761,31 @@ */ core.Text.prototype.determineFontProperties = function determineFontProperties(fontStyle) { - warn('determineFontProperties is now deprecated, please use the static calculateFontProperties method, ' - + 'e.g : Text.calculateFontProperties(fontStyle);'); + warn('determineFontProperties is now deprecated, please use TextMetrics.measureFont method'); - return core.Text.calculateFontProperties(fontStyle); + return core.TextMetrics.measureFont(fontStyle); +}; + +/** + * @method + * @name PIXI.Text.getFontStyle + * @see PIXI.TextMetrics.getFontStyle + * @deprecated since version 4.5.0 + * @param {PIXI.TextStyle} style - The style to use. + * @return {string} Font string + */ +core.Text.getFontStyle = function getFontStyle(style) +{ + warn('getFontStyle is now deprecated, please use TextStyle.toFontString() instead'); + + style = style || {}; + + if (!(style instanceof core.TextStyle)) + { + style = new core.TextStyle(style); + } + + return style.toFontString(); }; Object.defineProperties(core.TextStyle.prototype, { diff --git a/test/core/Text.js b/test/core/Text.js index 5799ad4..e678ba4 100644 --- a/test/core/Text.js +++ b/test/core/Text.js @@ -2,40 +2,6 @@ describe('PIXI.Text', function () { - describe('getFontStyle', function () - { - it('should be a valid API', function () - { - expect(PIXI.Text.getFontStyle).to.be.a.function; - }); - - it('should assume pixel fonts', function () - { - const style = PIXI.Text.getFontStyle({ fontSize: 72 }); - - expect(style).to.be.a.string; - expect(style).to.have.string(' 72px '); - }); - - it('should handle multiple fonts as array', function () - { - const style = PIXI.Text.getFontStyle({ - fontFamily: ['Georgia', 'Arial', 'sans-serif'], - }); - - expect(style).to.have.string('"Georgia","Arial","sans-serif"'); - }); - - it('should handle multiple fonts as string', function () - { - const style = PIXI.Text.getFontStyle({ - fontFamily: 'Georgia, "Arial", sans-serif', - }); - - expect(style).to.have.string('"Georgia","Arial","sans-serif"'); - }); - }); - describe('destroy', function () { it('should call through to Sprite.destroy', function () diff --git a/src/core/index.js b/src/core/index.js index f03fe15..3265dfb 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -26,6 +26,7 @@ export { default as SpriteRenderer } from './sprites/webgl/SpriteRenderer'; export { default as Text } from './text/Text'; export { default as TextStyle } from './text/TextStyle'; +export { default as TextMetrics } from './text/TextMetrics'; export { default as Graphics } from './graphics/Graphics'; export { default as GraphicsData } from './graphics/GraphicsData'; export { default as GraphicsRenderer } from './graphics/webgl/GraphicsRenderer'; diff --git a/src/core/text/Text.js b/src/core/text/Text.js index 065a4f5..c11239a 100644 --- a/src/core/text/Text.js +++ b/src/core/text/Text.js @@ -6,6 +6,7 @@ import { TEXT_GRADIENT } from '../const'; import settings from '../settings'; import TextStyle from './TextStyle'; +import TextMetrics from './TextMetrics'; import trimCanvas from '../utils/trimCanvas'; const defaultDestroyOptions = { @@ -131,50 +132,18 @@ return; } - this._font = Text.getFontStyle(style); + this._font = this._style.toFontString(); - this.context.font = this._font; - - // word wrap - // preserve original text - const outputText = style.wordWrap ? this.wordWrap(this._text) : this._text; - - // split text into lines - const lines = outputText.split(/(?:\r\n|\r|\n)/); - - // calculate text width - const lineWidths = new Array(lines.length); - let maxLineWidth = 0; - const fontProperties = Text.calculateFontProperties(this._font); - - for (let i = 0; i < lines.length; i++) - { - const lineWidth = this.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 measured = TextMetrics.measureText(this._text, this._style, this._style.wordWrap, this.canvas); + const width = measured.width; + const height = measured.height; + const lines = measured.lines; + const lineHeight = measured.lineHeight; + const lineWidths = measured.lineWidths; + const maxLineWidth = measured.maxLineWidth; + const fontProperties = measured.fontProperties; this.canvas.width = Math.ceil((width + (style.padding * 2)) * this.resolution); - - // calculate text height - const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness; - - let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) - + ((lines.length - 1) * lineHeight); - - if (style.dropShadow) - { - height += style.dropShadowDistance; - } - this.canvas.height = Math.ceil((height + (style.padding * 2)) * this.resolution); this.context.scale(this.resolution, this.resolution); @@ -267,12 +236,21 @@ if (style.stroke && style.strokeThickness) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding, true); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding, + true + ); } if (style.fill) { - this.drawLetterSpacing(lines[i], linePositionX + style.padding, linePositionY + style.padding); + this.drawLetterSpacing( + lines[i], + linePositionX + style.padding, + linePositionY + style.padding + ); } } @@ -411,90 +389,6 @@ } /** - * 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 - * @return {string} New string with new lines applied where required - */ - wordWrap(text) - { - // Greedy wrapping algorithm that will wrap words as the line grows longer - // than its horizontal bounds. - let result = ''; - const style = this._style; - const lines = text.split('\n'); - const wordWrapWidth = style.wordWrapWidth; - - for (let i = 0; i < lines.length; i++) - { - let spaceLeft = wordWrapWidth; - const words = lines[i].split(' '); - - for (let j = 0; j < words.length; j++) - { - const wordWidth = this.context.measureText(words[j]).width; - - if (style.breakWords && wordWidth > wordWrapWidth) - { - // Word should be split in the middle - const characters = words[j].split(''); - - for (let c = 0; c < characters.length; c++) - { - const characterWidth = this.context.measureText(characters[c]).width; - - if (characterWidth > spaceLeft) - { - result += `\n${characters[c]}`; - spaceLeft = wordWrapWidth - characterWidth; - } - else - { - if (c === 0) - { - result += ' '; - } - - result += characters[c]; - spaceLeft -= characterWidth; - } - } - } - else - { - const wordWidthWithSpace = wordWidth + this.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 (i < lines.length - 1) - { - result += '\n'; - } - } - - return result; - } - - /** * Gets the local bounds of the text object. * * @param {Rectangle} rect - The output rectangle. @@ -759,157 +653,4 @@ this._text = text; this.dirty = true; } - - /** - * Generates a font style string to use for Text.calculateFontProperties(). Takes the same parameter - * as Text.style. - * - * @static - * @param {object|TextStyle} style - String representing the style of the font - * @return {string} Font style string, for passing to Text.calculateFontProperties() - */ - static getFontStyle(style) - { - style = style || {}; - - if (!(style instanceof TextStyle)) - { - style = new TextStyle(style); - } - - // build canvas api font setting from individual components. Convert a numeric style.fontSize to px - const fontSizeString = (typeof style.fontSize === 'number') ? `${style.fontSize}px` : style.fontSize; - - // Clean-up fontFamily property by quoting each font name - // this will support font names with spaces - let fontFamilies = style.fontFamily; - - if (!Array.isArray(style.fontFamily)) - { - fontFamilies = style.fontFamily.split(','); - } - - for (let i = fontFamilies.length - 1; i >= 0; i--) - { - // Trim any extra white-space - let fontFamily = fontFamilies[i].trim(); - - // Check if font already contains strings - if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) - { - fontFamily = `"${fontFamily}"`; - } - fontFamilies[i] = fontFamily; - } - - return `${style.fontStyle} ${style.fontVariant} ${style.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; - } - - /** - * Calculates the ascent, descent and fontSize of a given fontStyle - * - * @static - * @param {string} fontStyle - String representing the style of the font - * @return {Object} Font properties object - */ - static calculateFontProperties(fontStyle) - { - // as this method is used for preparing assets, don't recalculate things if we don't need to - if (Text.fontPropertiesCache[fontStyle]) - { - return Text.fontPropertiesCache[fontStyle]; - } - - const properties = {}; - - const canvas = Text.fontPropertiesCanvas; - const context = Text.fontPropertiesContext; - - context.font = fontStyle; - - 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 = fontStyle; - - 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; - - Text.fontPropertiesCache[fontStyle] = properties; - - return properties; - } } - -Text.fontPropertiesCache = {}; -Text.fontPropertiesCanvas = document.createElement('canvas'); -Text.fontPropertiesContext = Text.fontPropertiesCanvas.getContext('2d'); diff --git a/src/core/text/TextMetrics.js b/src/core/text/TextMetrics.js new file mode 100644 index 0000000..27bd145 --- /dev/null +++ b/src/core/text/TextMetrics.js @@ -0,0 +1,327 @@ +/** + * The TextMetrics object represents the measurement of a block of text with a specified 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); + + if (style.dropShadow) + { + height += style.dropShadowDistance; + } + + return new TextMetrics( + text, + style, + width, + height, + lines, + lineWidths, + lineHeight, + 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'); + + // 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 = {}; + + for (let i = 0; i < lines.length; i++) + { + let spaceLeft = wordWrapWidth; + const words = lines[i].split(' '); + + for (let j = 0; j < words.length; j++) + { + const wordWidth = context.measureText(words[j]).width; + + if (style.breakWords && wordWidth > wordWrapWidth) + { + // 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; + } + } + } + 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 (i < lines.length - 1) + { + result += '\n'; + } + } + + return result; + } + + /** + * 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 = {}; diff --git a/src/core/text/TextStyle.js b/src/core/text/TextStyle.js index 890950b..4d66953 100644 --- a/src/core/text/TextStyle.js +++ b/src/core/text/TextStyle.js @@ -473,6 +473,41 @@ this.styleID++; } } + + /** + * Generates a font style string to use for `TextMetrics.measureFont()`. + * + * @return {string} Font style string, for passing to `TextMetrics.measureFont()` + */ + toFontString() + { + // build canvas api font setting from individual components. Convert a numeric this.fontSize to px + const fontSizeString = (typeof this.fontSize === 'number') ? `${this.fontSize}px` : this.fontSize; + + // Clean-up fontFamily property by quoting each font name + // this will support font names with spaces + let fontFamilies = this.fontFamily; + + if (!Array.isArray(this.fontFamily)) + { + fontFamilies = this.fontFamily.split(','); + } + + for (let i = fontFamilies.length - 1; i >= 0; i--) + { + // Trim any extra white-space + let fontFamily = fontFamilies[i].trim(); + + // Check if font already contains strings + if (!(/([\"\'])[^\'\"]+\1/).test(fontFamily)) + { + fontFamily = `"${fontFamily}"`; + } + fontFamilies[i] = fontFamily; + } + + return `${this.fontStyle} ${this.fontVariant} ${this.fontWeight} ${fontSizeString} ${fontFamilies.join(',')}`; + } } /** diff --git a/src/deprecation.js b/src/deprecation.js index e7fe5ce..0a9a771 100644 --- a/src/deprecation.js +++ b/src/deprecation.js @@ -695,6 +695,49 @@ }; /** + * Calculates the ascent, descent and fontSize of a given fontStyle + * + * @name PIXI.Text.calculateFontProperties + * @see PIXI.TextMetrics.measureFont + * @deprecated since version 4.5.0 + * @param {string} font - String representing the style of the font + * @return {Object} Font properties object + */ +core.Text.calculateFontProperties = function calculateFontProperties(font) +{ + warn(`Text.calculateFontProperties is now deprecated, please use the TextMetrics.measureFont`); + + return core.TextMetrics.measureFont(font); +}; + +Object.defineProperties(core.Text, { + fontPropertiesCache: { + get() + { + warn(`Text.fontPropertiesCache is deprecated`); + + return core.TextMetrics._fonts; + }, + }, + fontPropertiesCanvas: { + get() + { + warn(`Text.fontPropertiesCanvas is deprecated`); + + return core.TextMetrics._canvas; + }, + }, + fontPropertiesContext: { + get() + { + warn(`Text.fontPropertiesContext is deprecated`); + + return core.TextMetrics._context; + }, + }, +}); + +/** * @method * @name PIXI.Text#setStyle * @see PIXI.Text#style @@ -710,7 +753,7 @@ /** * @method * @name PIXI.Text#determineFontProperties - * @see PIXI.Text#calculateFontProperties + * @see PIXI.Text#measureFontProperties * @deprecated since version 4.2.0 * @private * @param {string} fontStyle - String representing the style of the font @@ -718,10 +761,31 @@ */ core.Text.prototype.determineFontProperties = function determineFontProperties(fontStyle) { - warn('determineFontProperties is now deprecated, please use the static calculateFontProperties method, ' - + 'e.g : Text.calculateFontProperties(fontStyle);'); + warn('determineFontProperties is now deprecated, please use TextMetrics.measureFont method'); - return core.Text.calculateFontProperties(fontStyle); + return core.TextMetrics.measureFont(fontStyle); +}; + +/** + * @method + * @name PIXI.Text.getFontStyle + * @see PIXI.TextMetrics.getFontStyle + * @deprecated since version 4.5.0 + * @param {PIXI.TextStyle} style - The style to use. + * @return {string} Font string + */ +core.Text.getFontStyle = function getFontStyle(style) +{ + warn('getFontStyle is now deprecated, please use TextStyle.toFontString() instead'); + + style = style || {}; + + if (!(style instanceof core.TextStyle)) + { + style = new core.TextStyle(style); + } + + return style.toFontString(); }; Object.defineProperties(core.TextStyle.prototype, { diff --git a/test/core/Text.js b/test/core/Text.js index 5799ad4..e678ba4 100644 --- a/test/core/Text.js +++ b/test/core/Text.js @@ -2,40 +2,6 @@ describe('PIXI.Text', function () { - describe('getFontStyle', function () - { - it('should be a valid API', function () - { - expect(PIXI.Text.getFontStyle).to.be.a.function; - }); - - it('should assume pixel fonts', function () - { - const style = PIXI.Text.getFontStyle({ fontSize: 72 }); - - expect(style).to.be.a.string; - expect(style).to.have.string(' 72px '); - }); - - it('should handle multiple fonts as array', function () - { - const style = PIXI.Text.getFontStyle({ - fontFamily: ['Georgia', 'Arial', 'sans-serif'], - }); - - expect(style).to.have.string('"Georgia","Arial","sans-serif"'); - }); - - it('should handle multiple fonts as string', function () - { - const style = PIXI.Text.getFontStyle({ - fontFamily: 'Georgia, "Arial", sans-serif', - }); - - expect(style).to.have.string('"Georgia","Arial","sans-serif"'); - }); - }); - describe('destroy', function () { it('should call through to Sprite.destroy', function () diff --git a/test/core/TextStyle.js b/test/core/TextStyle.js index b5761ad..49b99ef 100644 --- a/test/core/TextStyle.js +++ b/test/core/TextStyle.js @@ -23,4 +23,31 @@ expect(textStyle.fontSize).to.equal(1000); expect(clonedTextStyle.fontSize).to.equal(textStyle.fontSize); }); + + it('should assume pixel fonts', function () + { + const style = new PIXI.TextStyle({ fontSize: 72 }); + const font = style.toFontString(); + + expect(font).to.be.a.string; + expect(font).to.have.string(' 72px '); + }); + + it('should handle multiple fonts as array', function () + { + const style = new PIXI.TextStyle({ + fontFamily: ['Georgia', 'Arial', 'sans-serif'], + }); + + expect(style.toFontString()).to.have.string('"Georgia","Arial","sans-serif"'); + }); + + it('should handle multiple fonts as string', function () + { + const style = new PIXI.TextStyle({ + fontFamily: 'Georgia, "Arial", sans-serif', + }); + + expect(style.toFontString()).to.have.string('"Georgia","Arial","sans-serif"'); + }); });