Source: packages/text/src/TextMetrics.ts

import { settings } from '@pixi/core';
import type { TextStyle, TextStyleWhiteSpace } from './TextStyle';

interface IFontMetrics
{
    ascent: number;
    descent: number;
    fontSize: number;
}

type CharacterWidthCache = { [key: string]: number };

/**
 * The TextMetrics object represents the measurement of a block of text with a specified style.
 *
 * ```js
 * let style = new PIXI.TextStyle({fontFamily : 'Arial', fontSize: 24, fill : 0xff1010, align : 'center'})
 * let textMetrics = PIXI.TextMetrics.measureText('Your text', style)
 * ```
 * @memberof PIXI
 */
export class TextMetrics
{
    /** 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.
     * @type {PIXI.IFontMetrics}
     */
    public fontProperties: IFontMetrics;

    public static METRICS_STRING: string;
    public static BASELINE_SYMBOL: string;
    public static BASELINE_MULTIPLIER: number;
    public static HEIGHT_MULTIPLIER: number;

    private static __canvas: HTMLCanvasElement | OffscreenCanvas;
    private static __context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;

    // TODO: These should be protected but they're initialized outside of the class.
    public static _fonts: { [font: string]: IFontMetrics };
    public static _newlines: number[];
    public static _breakingSpaces: number[];

    /**
     * @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 {PIXI.IFontMetrics} 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: IFontMetrics)
    {
        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 wordWrap - Override for if word-wrap should be applied to the text.
     * @param canvas - optional specification of the canvas to use for measuring.
     * @returns Measured width and height of the text.
     */
    public static measureText(
        text: string,
        style: TextStyle,
        wordWrap?: boolean,
        canvas: HTMLCanvasElement | OffscreenCanvas = TextMetrics._canvas
    ): TextMetrics
    {
        wordWrap = (wordWrap === undefined || wordWrap === null) ? style.wordWrap : wordWrap;
        const font = style.toFontString();
        const fontProperties = TextMetrics.measureFont(font);

        // fallback in case UA disallow canvas data extraction
        // (toDataURI, getImageData functions)
        if (fontProperties.fontSize === 0)
        {
            fontProperties.fontSize = style.fontSize as number;
            fontProperties.ascent = style.fontSize as number;
        }

        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<number>(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.
     * @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: HTMLCanvasElement | OffscreenCanvas = TextMetrics._canvas
    ): string
    {
        const context = canvas.getContext('2d');

        let width = 0;
        let line = '';
        let lines = '';

        const cache: CharacterWidthCache = Object.create(null);
        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 = TextMetrics.wordWrapSplit(token);

                    // 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;
                    }
                }

                // run word out of the bounds
                else
                {
                    // if there are words in this line already
                    // finish that line and start a new one
                    if (line.length > 0)
                    {
                        lines += TextMetrics.addLine(line);
                        line = '';
                        width = 0;
                    }

                    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;
                }
            }

            // 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 += 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.
     * @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 = TextMetrics.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: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D): number
    {
        let width = cache[key];

        if (typeof width !== 'number')
        {
            const spacing = ((key.length) * letterSpacing);

            width = context.measureText(key).width + spacing;
            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 (!TextMetrics.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 TextMetrics._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.
     */
    static isBreakingSpace(char: string, _nextChar?: string): boolean
    {
        if (typeof char !== 'string')
        {
            return false;
        }

        return TextMetrics._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 (TextMetrics.isBreakingSpace(char, nextChar) || TextMetrics.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
     */
    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
     */
    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.
     * @example
     * // Correctly splits emojis, eg "🤪🤪" will result in two element array, each with one emoji.
     * TextMetrics.wordWrapSplit = (token) => [...token];
     * @param  token - The token to split
     * @returns The characters of the token
     */
    static wordWrapSplit(token: string): string[]
    {
        return token.split('');
    }

    /**
     * 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): IFontMetrics
    {
        // 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: IFontMetrics = {
            ascent: 0,
            descent: 0,
            fontSize: 0,
        };

        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 = Math.ceil(TextMetrics.HEIGHT_MULTIPLIER * 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.
     * @param {string} [font] - font name. If font name not set then clear cache for all fonts.
     */
    public static clearMetrics(font = ''): void
    {
        if (font)
        {
            delete TextMetrics._fonts[font];
        }
        else
        {
            TextMetrics._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(): HTMLCanvasElement | OffscreenCanvas
    {
        if (!TextMetrics.__canvas)
        {
            let canvas: HTMLCanvasElement | OffscreenCanvas;

            try
            {
                // OffscreenCanvas2D measureText can be up to 40% faster.
                const c = new OffscreenCanvas(0, 0);
                const context = c.getContext('2d');

                if (context?.measureText)
                {
                    TextMetrics.__canvas = c;

                    return c;
                }

                canvas = settings.ADAPTER.createCanvas();
            }
            catch (ex)
            {
                canvas = settings.ADAPTER.createCanvas();
            }
            canvas.width = canvas.height = 10;
            TextMetrics.__canvas = canvas;
        }

        return TextMetrics.__canvas;
    }

    /**
     * TODO: this should be private, but isn't because of backward compat, will fix later.
     * @ignore
     */
    public static get _context(): CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D
    {
        if (!TextMetrics.__context)
        {
            TextMetrics.__context = TextMetrics._canvas.getContext('2d');
        }

        return TextMetrics.__context;
    }
}

/**
 * Internal return object for `TextMetrics.measureFont`.
 * @typedef {object} FontMetrics
 * @property {number} ascent - The ascent distance
 * @property {number} descent - The descent distance
 * @property {number} fontSize - Font size from ascent to descent
 * @memberof PIXI.TextMetrics
 * @private
 */

/**
 * Cache of {@see PIXI.TextMetrics.FontMetrics} objects.
 * @memberof PIXI.TextMetrics
 * @type {object}
 * @private
 */
TextMetrics._fonts = {};

/**
 * String used for calculate font metrics.
 * These characters are all tall to help calculate the height required for text.
 * @static
 * @memberof PIXI.TextMetrics
 * @name METRICS_STRING
 * @type {string}
 * @default |ÉqÅ
 */
TextMetrics.METRICS_STRING = '|ÉqÅ';

/**
 * Baseline symbol for calculate font metrics.
 * @static
 * @memberof PIXI.TextMetrics
 * @name BASELINE_SYMBOL
 * @type {string}
 * @default M
 */
TextMetrics.BASELINE_SYMBOL = 'M';

/**
 * Baseline multiplier for calculate font metrics.
 * @static
 * @memberof PIXI.TextMetrics
 * @name BASELINE_MULTIPLIER
 * @type {number}
 * @default 1.4
 */
TextMetrics.BASELINE_MULTIPLIER = 1.4;

/**
 * Height multiplier for setting height of canvas to calculate font metrics.
 * @static
 * @memberof PIXI.TextMetrics
 * @name HEIGHT_MULTIPLIER
 * @type {number}
 * @default 2.00
 */
TextMetrics.HEIGHT_MULTIPLIER = 2.0;

/**
 * Cache of new line chars.
 * @memberof PIXI.TextMetrics
 * @type {number[]}
 * @private
 */
TextMetrics._newlines = [
    0x000A, // line feed
    0x000D, // carriage return
];

/**
 * Cache of breaking spaces.
 * @memberof PIXI.TextMetrics
 * @type {number[]}
 * @private
 */
TextMetrics._breakingSpaces = [
    0x0009, // character tabulation
    0x0020, // space
    0x2000, // en quad
    0x2001, // em quad
    0x2002, // en space
    0x2003, // em space
    0x2004, // three-per-em space
    0x2005, // four-per-em space
    0x2006, // six-per-em space
    0x2008, // punctuation space
    0x2009, // thin space
    0x200A, // hair space
    0x205F, // medium mathematical space
    0x3000, // ideographic space
];

/**
 * A number, or a string containing a number.
 * @memberof PIXI
 * @typedef {object} IFontMetrics
 * @property {number} ascent - Font ascent
 * @property {number} descent - Font descent
 * @property {number} fontSize - Font size
 */