import { DOMAdapter } from '../../../environment/adapter';
import { fontStringFromTextStyle } from './utils/fontStringFromTextStyle';
import type { ICanvas, ICanvasRenderingContext2DSettings } from '../../../environment/canvas/ICanvas';
import type { ICanvasRenderingContext2D } from '../../../environment/canvas/ICanvasRenderingContext2D';
import type { TextStyle, TextStyleWhiteSpace } from '../TextStyle';
// The type for Intl.Segmenter is only available since TypeScript 4.7.2, so let's make a polyfill for it.
interface ISegmentData
{
segment: string;
}
interface ISegments
{
[Symbol.iterator](): IterableIterator<ISegmentData>;
}
interface ISegmenter
{
segment(input: string): ISegments;
}
interface IIntl
{
Segmenter?: {
prototype: ISegmenter;
new(): ISegmenter;
};
}
/**
* A number, or a string containing a number.
* @memberof text
* @typedef {object} FontMetrics
* @property {number} ascent - Font ascent
* @property {number} descent - Font descent
* @property {number} fontSize - Font size
*/
export interface FontMetrics
{
ascent: number;
descent: number;
fontSize: number;
}
type CharacterWidthCache = Record<string, number>;
// Default settings used for all getContext calls
const contextSettings: ICanvasRenderingContext2DSettings = {
// TextMetrics requires getImageData readback for measuring fonts.
willReadFrequently: true,
};
/**
* The TextMetrics object represents the measurement of a block of text with a specified style.
* @example
* import { CanvasTextMetrics, TextStyle } from 'pixi.js';
*
* const style = new TextStyle({
* fontFamily: 'Arial',
* fontSize: 24,
* fill: 0xff1010,
* align: 'center',
* });
* const textMetrics = CanvasTextMetrics.measureText('Your text', style);
* @memberof text
*/
export class CanvasTextMetrics
{
/** The text that was measured. */
public text: string;
/** The style that was measured. */
public style: TextStyle;
/** The measured width of the text. */
public width: number;
/** The measured height of the text. */
public height: number;
/** An array of lines of the text broken by new lines and wrapping is specified in style. */
public lines: string[];
/** An array of the line widths for each line matched to `lines`. */
public lineWidths: number[];
/** The measured line height for this style. */
public lineHeight: number;
/** The maximum line width for all measured lines. */
public maxLineWidth: number;
/** The font properties object from TextMetrics.measureFont. */
public fontProperties: FontMetrics;
/**
* String used for calculate font metrics.
* These characters are all tall to help calculate the height required for text.
*/
public static METRICS_STRING = '|ÉqÅ';
/** Baseline symbol for calculate font metrics. */
public static BASELINE_SYMBOL = 'M';
/** Baseline multiplier for calculate font metrics. */
public static BASELINE_MULTIPLIER = 1.4;
/** Height multiplier for setting height of canvas to calculate font metrics. */
public static HEIGHT_MULTIPLIER = 2.0;
/**
* A Unicode "character", or "grapheme cluster", can be composed of multiple Unicode code points,
* such as letters with diacritical marks (e.g. `'\u0065\u0301'`, letter e with acute)
* or emojis with modifiers (e.g. `'\uD83E\uDDD1\u200D\uD83D\uDCBB'`, technologist).
* The new `Intl.Segmenter` API in ES2022 can split the string into grapheme clusters correctly. If it is not available,
* PixiJS will fallback to use the iterator of String, which can only spilt the string into code points.
* If you want to get full functionality in environments that don't support `Intl.Segmenter` (such as Firefox),
* you can use other libraries such as grapheme-splitter
* or graphemer to create a polyfill. Since these libraries can be
* relatively large in size to handle various Unicode grapheme clusters properly, PixiJS won't use them directly.
*/
public static graphemeSegmenter: (s: string) => string[] = (() =>
{
if (typeof (Intl as IIntl)?.Segmenter === 'function')
{
const segmenter = new (Intl as IIntl).Segmenter();
return (s: string) => [...segmenter.segment(s)].map((x) => x.segment);
}
return (s: string) => [...s];
})();
public static _experimentalLetterSpacingSupported?: boolean;
/**
* Checking that we can use modern canvas 2D API.
*
* Note: This is an unstable API, Chrome < 94 use `textLetterSpacing`, later versions use `letterSpacing`.
* @see TextMetrics.experimentalLetterSpacing
* @see https://developer.mozilla.org/en-US/docs/Web/API/ICanvasRenderingContext2D/letterSpacing
* @see https://developer.chrome.com/origintrials/#/view_trial/3585991203293757441
*/
public static get experimentalLetterSpacingSupported(): boolean
{
let result = CanvasTextMetrics._experimentalLetterSpacingSupported;
if (result !== undefined)
{
const proto = DOMAdapter.get().getCanvasRenderingContext2D().prototype;
result
= CanvasTextMetrics._experimentalLetterSpacingSupported
= 'letterSpacing' in proto || 'textLetterSpacing' in proto;
}
return result;
}
/**
* New rendering behavior for letter-spacing which uses Chrome's new native API. This will
* lead to more accurate letter-spacing results because it does not try to manually draw
* each character. However, this Chrome API is experimental and may not serve all cases yet.
* @see TextMetrics.experimentalLetterSpacingSupported
*/
public static experimentalLetterSpacing = false;
/** Cache of {@see TextMetrics.FontMetrics} objects. */
private static _fonts: Record<string, FontMetrics> = {};
/** Cache of new line chars. */
private static readonly _newlines: number[] = [
0x000A, // line feed
0x000D, // carriage return
];
/** Cache of breaking spaces. */
private static readonly _breakingSpaces: number[] = [
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
];
// eslint-disable-next-line @typescript-eslint/naming-convention
private static __canvas: ICanvas;
// eslint-disable-next-line @typescript-eslint/naming-convention
private static __context: ICanvasRenderingContext2D;
private static readonly _measurementCache: Record<string, CanvasTextMetrics> = {};
/**
* @param text - the text that was measured
* @param style - the style that was measured
* @param width - the measured width of the text
* @param height - the measured height of the text
* @param lines - an array of the lines of text broken by new lines and wrapping if specified in style
* @param lineWidths - an array of the line widths for each line matched to `lines`
* @param lineHeight - the measured line height for this style
* @param maxLineWidth - the maximum line width for all measured lines
* @param {FontMetrics} fontProperties - the font properties object from TextMetrics.measureFont
*/
constructor(text: string, style: TextStyle, width: number, height: number, lines: string[], lineWidths: number[],
lineHeight: number, maxLineWidth: number, fontProperties: FontMetrics)
{
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 text - The text to measure.
* @param style - The text style to use for measuring
* @param canvas - optional specification of the canvas to use for measuring.
* @param wordWrap
* @returns Measured width and height of the text.
*/
public static measureText(
text = ' ',
style: TextStyle,
canvas: ICanvas = CanvasTextMetrics._canvas,
wordWrap: boolean = style.wordWrap,
): CanvasTextMetrics
{
const textKey = `${text}:${style.styleKey}`;
// TODO - if we find this starts to go nuts with memory, we can remove the cache
// or instead just stick a usage tick that we increment each time we return it.
// if some are not used, we can just tidy them up!
if (CanvasTextMetrics._measurementCache[textKey]) return CanvasTextMetrics._measurementCache[textKey];
const font = fontStringFromTextStyle(style);
const fontProperties = CanvasTextMetrics.measureFont(font);
// fallback in case UA disallow canvas data extraction
if (fontProperties.fontSize === 0)
{
fontProperties.fontSize = style.fontSize as number;
fontProperties.ascent = style.fontSize as number;
}
const context = CanvasTextMetrics.__context; // canvas.getContext('2d', contextSettings);
context.font = font;
const outputText = wordWrap ? CanvasTextMetrics._wordWrap(text, style, canvas) : text;
const lines = outputText.split(/(?:\r\n|\r|\n)/);
const lineWidths = new Array<number>(lines.length);
let maxLineWidth = 0;
for (let i = 0; i < lines.length; i++)
{
const lineWidth = CanvasTextMetrics._measureText(lines[i], style.letterSpacing, context);
lineWidths[i] = lineWidth;
maxLineWidth = Math.max(maxLineWidth, lineWidth);
}
const strokeWidth = style._stroke?.width || 0;
let width = maxLineWidth + strokeWidth;
if (style.dropShadow)
{
width += style.dropShadow.distance;
}
const lineHeight = style.lineHeight || fontProperties.fontSize;
let height = Math.max(lineHeight, fontProperties.fontSize + (strokeWidth))
+ ((lines.length - 1) * (lineHeight + style.leading));
if (style.dropShadow)
{
height += style.dropShadow.distance;
}
const measurements = new CanvasTextMetrics(
text,
style,
width,
height,
lines,
lineWidths,
lineHeight + style.leading,
maxLineWidth,
fontProperties
);
// CanvasTextMetrics._measurementCache[textKey] = measurements;
return measurements;
}
private static _measureText(
text: string,
letterSpacing: number,
context: ICanvasRenderingContext2D
)
{
let useExperimentalLetterSpacing = false;
if (CanvasTextMetrics.experimentalLetterSpacingSupported)
{
if (CanvasTextMetrics.experimentalLetterSpacing)
{
context.letterSpacing = `${letterSpacing}px`;
context.textLetterSpacing = `${letterSpacing}px`;
useExperimentalLetterSpacing = true;
}
else
{
context.letterSpacing = '0px';
context.textLetterSpacing = '0px';
}
}
const metrics = context.measureText(text);
let metricWidth = metrics.width;
const actualBoundingBoxLeft = -metrics.actualBoundingBoxLeft;
const actualBoundingBoxRight = metrics.actualBoundingBoxRight;
let boundsWidth = actualBoundingBoxRight - actualBoundingBoxLeft;
if (metricWidth > 0)
{
if (useExperimentalLetterSpacing)
{
metricWidth -= letterSpacing;
boundsWidth -= letterSpacing;
}
else
{
const val = (CanvasTextMetrics.graphemeSegmenter(text).length - 1) * letterSpacing;
metricWidth += val;
boundsWidth += val;
}
}
// NOTE: this is a bit of a hack as metrics.width and the bounding box width do not measure the same thing
// We can't seem to exclusively use one or the other, so are taking the largest of the two
return Math.max(metricWidth, boundsWidth);
}
/**
* Applies newlines to a string to have it optimally fit into the horizontal
* bounds set by the Text object's wordWrapWidth property.
* @param text - String to apply word wrapping to
* @param style - the style to use when wrapping
* @param canvas - optional specification of the canvas to use for measuring.
* @returns New string with new lines applied where required
*/
private static _wordWrap(
text: string,
style: TextStyle,
canvas: ICanvas = CanvasTextMetrics._canvas
): string
{
const context = canvas.getContext('2d', contextSettings);
let width = 0;
let line = '';
let lines = '';
const cache: CharacterWidthCache = Object.create(null);
const { letterSpacing, whiteSpace } = style;
// How to handle whitespaces
const collapseSpaces = CanvasTextMetrics._collapseSpaces(whiteSpace);
const collapseNewlines = CanvasTextMetrics._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 = CanvasTextMetrics._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 (CanvasTextMetrics._isNewline(token))
{
// keep the new line
if (!collapseNewlines)
{
lines += CanvasTextMetrics._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 = CanvasTextMetrics.isBreakingSpace(token);
const lastIsBreakingSpace = CanvasTextMetrics.isBreakingSpace(line[line.length - 1]);
if (currIsBreakingSpace && lastIsBreakingSpace)
{
continue;
}
}
// get word width from cache if possible
const tokenWidth = CanvasTextMetrics._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 += CanvasTextMetrics._addLine(line);
line = '';
width = 0;
}
// break large word over multiple lines
if (CanvasTextMetrics.canBreakWords(token, style.breakWords))
{
// break word into characters
const characters = CanvasTextMetrics.wordWrapSplit(token);
// loop the characters
for (let j = 0; j < characters.length; j++)
{
let char = characters[j];
let lastChar = char;
let k = 1;
// we are not at the end of the token
while (characters[j + k])
{
const nextChar = characters[j + k];
// should not split chars
if (!CanvasTextMetrics.canBreakChars(lastChar, nextChar, token, j, style.breakWords))
{
// combine chars & move forward one
char += nextChar;
}
else
{
break;
}
lastChar = nextChar;
k++;
}
j += k - 1;
const characterWidth = CanvasTextMetrics._getFromCache(char, letterSpacing, cache, context);
if (characterWidth + width > wordWrapWidth)
{
lines += CanvasTextMetrics._addLine(line);
canPrependSpaces = false;
line = '';
width = 0;
}
line += char;
width += characterWidth;
}
}
// run word out of the bounds
else
{
// if there are words in this line already
// finish that line and start a new one
if (line.length > 0)
{
lines += CanvasTextMetrics._addLine(line);
line = '';
width = 0;
}
const isLastToken = i === tokens.length - 1;
// give it its own line if it's not the end
lines += CanvasTextMetrics._addLine(token, !isLastToken);
canPrependSpaces = false;
line = '';
width = 0;
}
}
// word could fit
else
{
// word won't fit because of existing words
// start a new line
if (tokenWidth + width > wordWrapWidth)
{
// if its a space we don't want it
canPrependSpaces = false;
// add a new line
lines += CanvasTextMetrics._addLine(line);
// start a new line
line = '';
width = 0;
}
// don't add spaces to the beginning of lines
if (line.length > 0 || !CanvasTextMetrics.isBreakingSpace(token) || canPrependSpaces)
{
// add the word to the current line
line += token;
// update width counter
width += tokenWidth;
}
}
}
lines += CanvasTextMetrics._addLine(line, false);
return lines;
}
/**
* Convenience function for logging each line added during the wordWrap method.
* @param line - The line of text to add
* @param newLine - Add new line character to end
* @returns A formatted line
*/
private static _addLine(line: string, newLine = true): string
{
line = CanvasTextMetrics._trimRight(line);
line = (newLine) ? `${line}\n` : line;
return line;
}
/**
* Gets & sets the widths of calculated characters in a cache object
* @param key - The key
* @param letterSpacing - The letter spacing
* @param cache - The cache
* @param context - The canvas context
* @returns The from cache.
*/
private static _getFromCache(key: string, letterSpacing: number, cache: CharacterWidthCache,
context: ICanvasRenderingContext2D): number
{
let width = cache[key];
if (typeof width !== 'number')
{
width = CanvasTextMetrics._measureText(key, letterSpacing, context) + letterSpacing;
cache[key] = width;
}
return width;
}
/**
* Determines whether we should collapse breaking spaces.
* @param whiteSpace - The TextStyle property whiteSpace
* @returns Should collapse
*/
private static _collapseSpaces(whiteSpace: TextStyleWhiteSpace): boolean
{
return (whiteSpace === 'normal' || whiteSpace === 'pre-line');
}
/**
* Determines whether we should collapse newLine chars.
* @param whiteSpace - The white space
* @returns should collapse
*/
private static _collapseNewlines(whiteSpace: TextStyleWhiteSpace): boolean
{
return (whiteSpace === 'normal');
}
/**
* Trims breaking whitespaces from string.
* @param text - The text
* @returns Trimmed string
*/
private static _trimRight(text: string): string
{
if (typeof text !== 'string')
{
return '';
}
for (let i = text.length - 1; i >= 0; i--)
{
const char = text[i];
if (!CanvasTextMetrics.isBreakingSpace(char))
{
break;
}
text = text.slice(0, -1);
}
return text;
}
/**
* Determines if char is a newline.
* @param char - The character
* @returns True if newline, False otherwise.
*/
private static _isNewline(char: string): boolean
{
if (typeof char !== 'string')
{
return false;
}
return CanvasTextMetrics._newlines.includes(char.charCodeAt(0));
}
/**
* Determines if char is a breaking whitespace.
*
* It allows one to determine whether char should be a breaking whitespace
* For example certain characters in CJK langs or numbers.
* It must return a boolean.
* @param char - The character
* @param [_nextChar] - The next character
* @returns True if whitespace, False otherwise.
*/
public static isBreakingSpace(char: string, _nextChar?: string): boolean
{
if (typeof char !== 'string')
{
return false;
}
return CanvasTextMetrics._breakingSpaces.includes(char.charCodeAt(0));
}
/**
* Splits a string into words, breaking-spaces and newLine characters
* @param text - The text
* @returns A tokenized array
*/
private static _tokenize(text: string): string[]
{
const tokens: string[] = [];
let token = '';
if (typeof text !== 'string')
{
return tokens;
}
for (let i = 0; i < text.length; i++)
{
const char = text[i];
const nextChar = text[i + 1];
if (CanvasTextMetrics.isBreakingSpace(char, nextChar) || CanvasTextMetrics._isNewline(char))
{
if (token !== '')
{
tokens.push(token);
token = '';
}
tokens.push(char);
continue;
}
token += char;
}
if (token !== '')
{
tokens.push(token);
}
return tokens;
}
/**
* Overridable helper method used internally by TextMetrics, exposed to allow customizing the class's behavior.
*
* It allows one to customise which words should break
* Examples are if the token is CJK or numbers.
* It must return a boolean.
* @param _token - The token
* @param breakWords - The style attr break words
* @returns Whether to break word or not
*/
public static canBreakWords(_token: string, breakWords: boolean): boolean
{
return breakWords;
}
/**
* Overridable helper method used internally by TextMetrics, exposed to allow customizing the class's behavior.
*
* 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.
* @param _char - The character
* @param _nextChar - The next character
* @param _token - The token/word the characters are from
* @param _index - The index in the token of the char
* @param _breakWords - The style attr break words
* @returns whether to break word or not
*/
public static canBreakChars(_char: string, _nextChar: string, _token: string, _index: number,
_breakWords: boolean): boolean
{
return true;
}
/**
* Overridable helper method used internally by TextMetrics, exposed to allow customizing the class's behavior.
*
* It is called when a token (usually a word) has to be split into separate pieces
* in order to determine the point to break a word.
* It must return an array of characters.
* @param token - The token to split
* @returns The characters of the token
* @see CanvasTextMetrics.graphemeSegmenter
*/
public static wordWrapSplit(token: string): string[]
{
return CanvasTextMetrics.graphemeSegmenter(token);
}
/**
* Calculates the ascent, descent and fontSize of a given font-style
* @param font - String representing the style of the font
* @returns Font properties object
*/
public static measureFont(font: string): FontMetrics
{
// as this method is used for preparing assets, don't recalculate things if we don't need to
if (CanvasTextMetrics._fonts[font])
{
return CanvasTextMetrics._fonts[font];
}
const context = CanvasTextMetrics._context;
context.font = font;
const metrics = context.measureText(CanvasTextMetrics.METRICS_STRING + CanvasTextMetrics.BASELINE_SYMBOL);
const properties = {
ascent: metrics.actualBoundingBoxAscent,
descent: metrics.actualBoundingBoxDescent,
fontSize: metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent
};
CanvasTextMetrics._fonts[font] = properties;
return properties;
}
/**
* Clear font metrics in metrics cache.
* @param {string} [font] - font name. If font name not set then clear cache for all fonts.
*/
public static clearMetrics(font = ''): void
{
if (font)
{
delete CanvasTextMetrics._fonts[font];
}
else
{
CanvasTextMetrics._fonts = {};
}
}
/**
* Cached canvas element for measuring text
* TODO: this should be private, but isn't because of backward compat, will fix later.
* @ignore
*/
public static get _canvas(): ICanvas
{
if (!CanvasTextMetrics.__canvas)
{
let canvas: ICanvas;
try
{
// OffscreenCanvas2D measureText can be up to 40% faster.
const c = new OffscreenCanvas(0, 0);
const context = c.getContext('2d', contextSettings);
if (context?.measureText)
{
CanvasTextMetrics.__canvas = c as ICanvas;
return c as ICanvas;
}
canvas = DOMAdapter.get().createCanvas();
}
catch (_cx)
{
canvas = DOMAdapter.get().createCanvas();
}
canvas.width = canvas.height = 10;
CanvasTextMetrics.__canvas = canvas;
}
return CanvasTextMetrics.__canvas;
}
/**
* TODO: this should be private, but isn't because of backward compat, will fix later.
* @ignore
*/
public static get _context(): ICanvasRenderingContext2D
{
if (!CanvasTextMetrics.__context)
{
CanvasTextMetrics.__context = CanvasTextMetrics._canvas.getContext('2d', contextSettings);
}
return CanvasTextMetrics.__context;
}
}