/**
* The TextMetrics object represents the measurement of a block of text with a specified style.
*
* ```js
* let style = new Tiny.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'})
* let textMetrics = Tiny.TextMetrics.measureText('Your text', style)
* ```
*
* @class
* @memberOf Tiny
*/
export default class TextMetrics {
/**
* @param {string} text - the text that was measured
* @param {Tiny.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 {Tiny.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 {Tiny.TextMetrics} measured width and height of the text.
*/
static measureText(text, style, wordWrap, canvas = TextMetrics._canvas) {
wordWrap = (wordWrap === undefined || wordWrap === null) ? style.wordWrap : wordWrap;
const font = style.toFontString();
const fontProperties = TextMetrics.measureFont(font);
const context = canvas.getContext('2d');
context.font = font;
const outputText = wordWrap ? TextMetrics.wordWrap(text, style, canvas) : text;
const lines = outputText.split(/(?:\r\n|\r|\n)/);
const lineWidths = new Array(lines.length);
let maxLineWidth = 0;
for (let i = 0; i < lines.length; i++) {
const lineWidth = context.measureText(lines[i]).width + ((lines[i].length - 1) * style.letterSpacing);
lineWidths[i] = lineWidth;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
}
let width = maxLineWidth + style.strokeThickness;
if (style.dropShadow) {
width += style.dropShadowDistance;
}
const lineHeight = style.lineHeight || fontProperties.fontSize + style.strokeThickness;
let height = Math.max(lineHeight, fontProperties.fontSize + style.strokeThickness) + ((lines.length - 1) * (lineHeight + style.leading));
if (style.dropShadow) {
height += style.dropShadowDistance;
}
return new TextMetrics(
text,
style,
width,
height,
lines,
lineWidths,
lineHeight + style.leading,
maxLineWidth,
fontProperties
);
}
/**
* Applies newlines to a string to have it optimally fit into the horizontal bounds set by the Text object's wordWrapWidth property.
*
* @private
* @param {string} text - String to apply word wrapping to
* @param {Tiny.TextStyle} style - the style to use when wrapping
* @param {HTMLCanvasElement} [canvas] - optional specification of the canvas to use for measuring.
* @return {string} New string with new lines applied where required
*/
static wordWrap(text, style, canvas = TextMetrics._canvas) {
const context = canvas.getContext('2d');
let width = 0;
let line = '';
let lines = '';
const cache = {};
const { letterSpacing, whiteSpace } = style;
// How to handle whitespaces
const collapseSpaces = TextMetrics.collapseSpaces(whiteSpace);
const collapseNewlines = TextMetrics.collapseNewlines(whiteSpace);
// whether or not spaces may be added to the beginning of lines
let canPrependSpaces = !collapseSpaces;
// There is letterSpacing after every char except the last one
// t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!
// so for convenience the above needs to be compared to width + 1 extra letterSpace
// t_h_i_s_' '_i_s_' '_a_n_' '_e_x_a_m_p_l_e_' '_!_
// ________________________________________________
// And then the final space is simply no appended to each line
const wordWrapWidth = style.wordWrapWidth + letterSpacing;
// break text into words, spaces and newline chars
const tokens = TextMetrics.tokenize(text);
for (let i = 0; i < tokens.length; i++) {
// get the word, space or newlineChar
let token = tokens[i];
// if word is a new line
if (TextMetrics.isNewline(token)) {
// keep the new line
if (!collapseNewlines) {
lines += TextMetrics.addLine(line);
canPrependSpaces = !collapseSpaces;
line = '';
width = 0;
continue;
}
// if we should collapse new lines
// we simply convert it into a space
token = ' ';
}
// if we should collapse repeated whitespaces
if (collapseSpaces) {
// check both this and the last tokens for spaces
const currIsBreakingSpace = TextMetrics.isBreakingSpace(token);
const lastIsBreakingSpace = TextMetrics.isBreakingSpace(line[line.length - 1]);
if (currIsBreakingSpace && lastIsBreakingSpace) {
continue;
}
}
// get word width from cache if possible
const tokenWidth = TextMetrics.getFromCache(token, letterSpacing, cache, context);
// word is longer than desired bounds
if (tokenWidth > wordWrapWidth) {
// if we are not already at the beginning of a line
if (line !== '') {
// start newlines for overflow words
lines += TextMetrics.addLine(line);
line = '';
width = 0;
}
// break large word over multiple lines
if (TextMetrics.canBreakWords(token, style.breakWords)) {
// break word into characters
const characters = token.split('');
// loop the characters
for (let j = 0; j < characters.length; j++) {
let char = characters[j];
let k = 1;
// we are not at the end of the token
while (characters[j + k]) {
const nextChar = characters[j + k];
const lastChar = char[char.length - 1];
// should not split chars
if (!TextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords)) {
// combine chars & move forward one
char += nextChar;
} else {
break;
}
k++;
}
j += char.length - 1;
const characterWidth = TextMetrics.getFromCache(char, letterSpacing, cache, context);
if (characterWidth + width > wordWrapWidth) {
lines += TextMetrics.addLine(line);
canPrependSpaces = false;
line = '';
width = 0;
}
line += char;
width += characterWidth;
}
} else { // run word out of the bounds
// if there are words in this line already
// finish that line and start a new one
if (line.length > 0) {
lines += TextMetrics.addLine(line);
line = '';
width = 0;
}
const isLastToken = i === tokens.length - 1;
// give it its own line if it's not the end
lines += TextMetrics.addLine(token, !isLastToken);
canPrependSpaces = false;
line = '';
width = 0;
}
} else { // word could fit
// word won't fit because of existing words
// start a new line
if (tokenWidth + width > wordWrapWidth) {
// if its a space we don't want it
canPrependSpaces = false;
// add a new line
lines += TextMetrics.addLine(line);
// start a new line
line = '';
width = 0;
}
// don't add spaces to the beginning of lines
if (line.length > 0 || !TextMetrics.isBreakingSpace(token) || canPrependSpaces) {
// add the word to the current line
line += token;
// update width counter
width += tokenWidth;
}
}
}
lines += TextMetrics.addLine(line, false);
return lines;
}
/**
* Convienience function for logging each line added during the wordWrap method
*
* @private
* @version 1.2.0
* @param {string} line - The line of text to add
* @param {boolean} newLine - Add new line character to end
* @return {string} A formatted line
*/
static addLine(line, newLine = true) {
line = TextMetrics.trimRight(line);
line = (newLine) ? `${line}\n` : line;
return line;
}
/**
* Gets & sets the widths of calculated characters in a cache object
*
* @private
* @version 1.2.0
* @param {string} key - The key
* @param {number} letterSpacing - The letter spacing
* @param {object} cache - The cache
* @param {CanvasRenderingContext2D} context - The canvas context
* @return {number} The from cache.
*/
static getFromCache(key, letterSpacing, cache, context) {
let width = cache[key];
if (width === undefined) {
const spacing = ((key.length) * letterSpacing);
width = context.measureText(key).width + spacing;
cache[key] = width;
}
return width;
}
/**
* Determines whether we should collapse breaking spaces
*
* @private
* @version 1.2.0
* @param {string} whiteSpace - The TextStyle property whiteSpace
* @return {boolean} should collapse
*/
static collapseSpaces(whiteSpace) {
return (whiteSpace === 'normal' || whiteSpace === 'pre-line');
}
/**
* Determines whether we should collapse newLine chars
*
* @private
* @version 1.2.0
* @param {string} whiteSpace - The white space
* @return {boolean} should collapse
*/
static collapseNewlines(whiteSpace) {
return (whiteSpace === 'normal');
}
/**
* trims breaking whitespaces from string
*
* @private
* @version 1.2.0
* @param {string} text - The text
* @return {string} trimmed string
*/
static trimRight(text) {
if (typeof text !== 'string') {
return '';
}
for (let i = text.length - 1; i >= 0; i--) {
const char = text[i];
if (!TextMetrics.isBreakingSpace(char)) {
break;
}
text = text.slice(0, -1);
}
return text;
}
/**
* Determines if char is a newline.
*
* @private
* @version 1.2.0
* @param {string} char - The character
* @return {boolean} True if newline, False otherwise.
*/
static isNewline(char) {
if (typeof char !== 'string') {
return false;
}
return (TextMetrics._newlines.indexOf(char.charCodeAt(0)) >= 0);
}
/**
* Determines if char is a breaking whitespace.
*
* @private
* @version 1.2.0
* @param {string} char - The character
* @return {boolean} True if whitespace, False otherwise.
*/
static isBreakingSpace(char) {
if (typeof char !== 'string') {
return false;
}
return (TextMetrics._breakingSpaces.indexOf(char.charCodeAt(0)) >= 0);
}
/**
* Splits a string into words, breaking-spaces and newLine characters
*
* @private
* @version 1.2.0
* @param {string} text - The text
* @return {array} A tokenized array
*/
static tokenize(text) {
const tokens = [];
let token = '';
if (typeof text !== 'string') {
return tokens;
}
for (let i = 0; i < text.length; i++) {
const char = text[i];
if (TextMetrics.isBreakingSpace(char) || TextMetrics.isNewline(char)) {
if (token !== '') {
tokens.push(token);
token = '';
}
tokens.push(char);
continue;
}
token += char;
}
if (token !== '') {
tokens.push(token);
}
return tokens;
}
/**
* This method exists to be easily overridden
* It allows one to customise which words should break
* Examples are if the token is CJK or numbers.
* It must return a boolean.
*
* @private
* @version 1.2.0
* @param {string} token - The token
* @param {boolean} breakWords - The style attr break words
* @return {boolean} whether to break word or not
*/
static canBreakWords(token, breakWords) {
return breakWords;
}
/**
* This method exists to be easily overridden
* It allows one to determine whether a pair of characters should be broken by newlines
* For example certain characters in CJK langs or numbers.
* It must return a boolean.
*
* @private
* @param {string} char - The character
* @param {string} nextChar - The next character
* @param {string} token - The token/word the characters are from
* @param {number} index - The index in the token of the char
* @param {boolean} breakWords - The style attr break words
* @return {boolean} whether to break word or not
*/
static canBreakChars(char, nextChar, token, index, breakWords) {
return true;
}
/**
* Calculates the ascent, descent and fontSize of a given font-style
*
* @static
* @param {string} font - String representing the style of the font
* @return {Tiny.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 metricsString = TextMetrics.METRICS_STRING + TextMetrics.BASELINE_SYMBOL;
const width = Math.ceil(context.measureText(metricsString).width);
let baseline = Math.ceil(context.measureText(TextMetrics.BASELINE_SYMBOL).width);
const height = 2 * baseline;
baseline = baseline * TextMetrics.BASELINE_MULTIPLIER | 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(metricsString, 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;
}
/**
* Clear font metrics in metrics cache.
*
* @static
* @version 1.2.0
* @param {string} [font] - font name. If font name not set then clear cache for all fonts.
*/
static clearMetrics(font = '') {
if (font) {
delete TextMetrics._fonts[font];
} else {
TextMetrics._fonts = {};
}
}
}
/**
* Internal return object for {@link Tiny.TextMetrics.measureFont `TextMetrics.measureFont`}.
*
* @class FontMetrics
* @memberof Tiny.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 Tiny.TextMetrics
* @type {HTMLCanvasElement}
* @private
*/
TextMetrics._canvas = canvas;
/**
* Cache for context to use.
*
* @memberof Tiny.TextMetrics
* @type {CanvasRenderingContext2D}
* @private
*/
TextMetrics._context = canvas.getContext('2d');
/**
* Cache of Tiny.TextMetrics~FontMetrics objects.
*
* @memberof Tiny.TextMetrics
* @type {object}
* @private
*/
TextMetrics._fonts = {};
/**
* String used for calculate font metrics.
*
* @static
* @version 1.2.0
* @memberof Tiny.TextMetrics
* @name METRICS_STRING
* @type {string}
* @default |Éq
*/
TextMetrics.METRICS_STRING = '|Éq';
/**
* Baseline symbol for calculate font metrics.
*
* @static
* @version 1.2.0
* @memberof Tiny.TextMetrics
* @name BASELINE_SYMBOL
* @type {string}
* @default M
*/
TextMetrics.BASELINE_SYMBOL = 'M';
/**
* Baseline multiplier for calculate font metrics.
*
* @static
* @version 1.2.0
* @memberof Tiny.TextMetrics
* @name BASELINE_MULTIPLIER
* @type {number}
* @default 1.4
*/
TextMetrics.BASELINE_MULTIPLIER = 1.4;
/**
* Cache of new line chars.
*
* @version 1.2.0
* @memberof Tiny.TextMetrics
* @type {number[]}
* @private
*/
TextMetrics._newlines = [
0x000A, // line feed
0x000D, // carriage return
];
/**
* Cache of breaking spaces.
*
* @version 1.2.0
* @memberof Tiny.TextMetrics
* @type {number[]}
* @private
*/
TextMetrics._breakingSpaces = [
0x0009, // character tabulation
0x0020, // space
0x2000, // en quad
0x2001, // em quad
0x2002, // en space
0x2003, // em space
0x2004, // three-per-em space
0x2005, // four-per-em space
0x2006, // six-per-em space
0x2008, // punctuation space
0x2009, // thin space
0x200A, // hair space
0x205F, // medium mathematical space
0x3000, // ideographic space
];