Source: packages/text-html/src/HTMLTextStyle.ts

import { settings, utils } from '@pixi/core';
import { TextStyle } from '@pixi/text';

import type {
    ITextStyle,
    TextStyleFontStyle,
    TextStyleFontWeight,
    TextStyleLineJoin,
    TextStyleTextBaseline
} from '@pixi/text';

/**
 * HTMLText support more white-space options.
 * @memberof PIXI
 * @since 7.2.0
 * @see PIXI.IHTMLTextStyle
 */
export type HTMLTextStyleWhiteSpace = 'normal' | 'pre' | 'pre-line' | 'nowrap' | 'pre-wrap';

/**
 * FontFace display options.
 * @memberof PIXI
 * @since 7.3.0
 */
export type FontDisplay = 'auto' | 'block' | 'swap' | 'fallback' | 'optional';

// Subset of ITextStyle
type ITextStyleIgnore = 'whiteSpace'
| 'fillGradientStops'
| 'fillGradientType'
| 'miterLimit'
| 'textBaseline'
| 'trim'
| 'leading'
| 'lineJoin';

/**
 * Modifed versions from ITextStyle.
 * @memberof PIXI
 * @extends PIXI.ITextStyle
 * @since 7.2.0
 */
export interface IHTMLTextStyle extends Omit<ITextStyle, ITextStyleIgnore>
{
    /** White-space with expanded options. */
    whiteSpace: HTMLTextStyleWhiteSpace;
}

export interface IHTMLTextFontOptions extends Pick<IHTMLFont, 'weight' | 'style' | 'family'>
{
    /** font-display property */
    display: FontDisplay;
}

/**
 * Font information for HTMLText
 * @memberof PIXI
 * @since 7.2.0
 */
export interface IHTMLFont
{
    /** User-supplied URL request */
    originalUrl: string;
    /** Base64 string for font */
    dataSrc: string;
    /** FontFace installed in the document */
    fontFace: FontFace | null;
    /** Blob-based URL for font */
    src: string;
    /** Family name of font */
    family: string;
    /** Weight of the font */
    weight: TextStyleFontWeight;
    /** Style of the font */
    style: TextStyleFontStyle;
    /** Display property of the font */
    display: FontDisplay;
    /** Reference counter */
    refs: number;
}

/**
 * Used internally to restrict text style usage and convert easily to CSS.
 * @class
 * @memberof PIXI
 * @param {PIXI.ITextStyle|PIXI.IHTMLTextStyle} [style] - Style to copy.
 * @since 7.2.0
 */
export class HTMLTextStyle extends TextStyle
{
    /** The collection of installed fonts */
    public static availableFonts: Record<string, IHTMLFont> = {};

    /**
     * List of default options, these are largely the same as TextStyle,
     * with the exception of whiteSpace, which is set to 'normal' by default.
     */
    public static readonly defaultOptions: IHTMLTextStyle = {
        /** Align */
        align: 'left',
        /** Break words */
        breakWords: false,
        /** Drop shadow */
        dropShadow: false,
        /** Drop shadow alpha */
        dropShadowAlpha: 1,
        /**
         * Drop shadow angle
         * @type {number}
         * @default Math.PI / 6
         */
        dropShadowAngle: Math.PI / 6,
        /** Drop shadow blur */
        dropShadowBlur: 0,
        /** Drop shadow color */
        dropShadowColor: 'black',
        /** Drop shadow distance */
        dropShadowDistance: 5,
        /** Fill */
        fill: 'black',
        /** Font family */
        fontFamily: 'Arial',
        /** Font size */
        fontSize: 26,
        /** Font style */
        fontStyle: 'normal',
        /** Font variant */
        fontVariant: 'normal',
        /** Font weight */
        fontWeight: 'normal',
        /** Letter spacing */
        letterSpacing: 0,
        /** Line height */
        lineHeight: 0,
        /** Padding */
        padding: 0,
        /** Stroke */
        stroke: 'black',
        /** Stroke thickness */
        strokeThickness: 0,
        /** White space */
        whiteSpace: 'normal',
        /** Word wrap */
        wordWrap: false,
        /** Word wrap width */
        wordWrapWidth: 100,
    };

    /** For using custom fonts */
    private _fonts: IHTMLFont[] = [];

    /** List of internal style rules */
    private _overrides: string[] = [];

    /** Global rules or stylesheet, useful for creating rules for rendering */
    private _stylesheet = '';

    /** Track font changes internally */
    private fontsDirty = false;

    /**
     * Convert a TextStyle to HTMLTextStyle
     * @param originalStyle
     * @example
     * import {TextStyle } from 'pixi.js';
     * import {HTMLTextStyle} from '@pixi/text-html';
     * const style = new TextStyle();
     * const htmlStyle = HTMLTextStyle.from(style);
     */
    static from(originalStyle: TextStyle | Partial<IHTMLTextStyle>): HTMLTextStyle
    {
        return new HTMLTextStyle(Object.keys(HTMLTextStyle.defaultOptions)
            .reduce((obj, prop) => ({ ...obj, [prop]: originalStyle[prop as keyof IHTMLTextStyle] }), {})
        );
    }

    /** Clear the current font */
    public cleanFonts(): void
    {
        if (this._fonts.length > 0)
        {
            this._fonts.forEach((font) =>
            {
                URL.revokeObjectURL(font.src);
                font.refs--;
                if (font.refs === 0)
                {
                    if (font.fontFace)
                    {
                        document.fonts.delete(font.fontFace);
                    }
                    delete HTMLTextStyle.availableFonts[font.originalUrl];
                }
            });
            this.fontFamily = 'Arial';
            this._fonts.length = 0;
            this.styleID++;
            this.fontsDirty = true;
        }
    }

    /**
     * Because of how HTMLText renders, fonts need to be imported
     * @param url
     * @param options
     */
    public loadFont(url: string, options: Partial<IHTMLTextFontOptions> = {}): Promise<void>
    {
        const { availableFonts } = HTMLTextStyle;

        // Font is already installed
        if (availableFonts[url])
        {
            const font = availableFonts[url];

            this._fonts.push(font);
            font.refs++;
            this.styleID++;
            this.fontsDirty = true;

            return Promise.resolve();
        }

        return settings.ADAPTER.fetch(url)
            .then((response) => response.blob())
            .then(async (blob) => new Promise<[string, string]>((resolve, reject) =>
            {
                const src = URL.createObjectURL(blob);
                const reader = new FileReader();

                reader.onload = () => resolve([src, reader.result as string]);
                reader.onerror = reject;
                reader.readAsDataURL(blob);
            }))
            .then(async ([src, dataSrc]) =>
            {
                const font: IHTMLFont = Object.assign({
                    family: utils.path.basename(url, utils.path.extname(url)),
                    weight: 'normal',
                    style: 'normal',
                    display: 'auto',
                    src,
                    dataSrc,
                    refs: 1,
                    originalUrl: url,
                    fontFace: null,
                }, options);

                availableFonts[url] = font;
                this._fonts.push(font);
                this.styleID++;

                // Load it into the current DOM so we can properly measure it!
                const fontFace = new FontFace(font.family, `url(${font.src})`, {
                    weight: font.weight,
                    style: font.style,
                    display: font.display,
                });

                // Keep this reference so we can remove it later from document
                font.fontFace = fontFace;

                await fontFace.load();
                document.fonts.add(fontFace);
                await document.fonts.ready;

                this.styleID++;
                this.fontsDirty = true;
            });
    }

    /**
     * Add a style override, this can be any CSS property
     * it will override any built-in style. This is the
     * property and the value as a string (e.g., `color: red`).
     * This will override any other internal style.
     * @param {string} value - CSS style(s) to add.
     * @example
     * style.addOverride('background-color: red');
     */
    public addOverride(...value: string[]): void
    {
        const toAdd = value.filter((v) => !this._overrides.includes(v));

        if (toAdd.length > 0)
        {
            this._overrides.push(...toAdd);
            this.styleID++;
        }
    }

    /**
     * Remove any overrides that match the value.
     * @param {string} value - CSS style to remove.
     * @example
     * style.removeOverride('background-color: red');
     */
    public removeOverride(...value: string[]): void
    {
        const toRemove = value.filter((v) => this._overrides.includes(v));

        if (toRemove.length > 0)
        {
            this._overrides = this._overrides.filter((v) => !toRemove.includes(v));
            this.styleID++;
        }
    }

    /**
     * Internally converts all of the style properties into CSS equivalents.
     * @param scale
     * @returns The CSS style string, for setting `style` property of root HTMLElement.
     */
    public toCSS(scale: number): string
    {
        return [
            `transform: scale(${scale})`,
            `transform-origin: top left`,
            'display: inline-block',
            `color: ${this.normalizeColor(this.fill)}`,
            `font-size: ${(this.fontSize as number)}px`,
            `font-family: ${this.fontFamily}`,
            `font-weight: ${this.fontWeight}`,
            `font-style: ${this.fontStyle}`,
            `font-variant: ${this.fontVariant}`,
            `letter-spacing: ${this.letterSpacing}px`,
            `text-align: ${this.align}`,
            `padding: ${this.padding}px`,
            `white-space: ${this.whiteSpace}`,
            ...this.lineHeight ? [`line-height: ${this.lineHeight}px`] : [],
            ...this.wordWrap ? [
                `word-wrap: ${this.breakWords ? 'break-all' : 'break-word'}`,
                `max-width: ${this.wordWrapWidth}px`
            ] : [],
            ...this.strokeThickness ? [
                `-webkit-text-stroke-width: ${this.strokeThickness}px`,
                `-webkit-text-stroke-color: ${this.normalizeColor(this.stroke)}`,
                `text-stroke-width: ${this.strokeThickness}px`,
                `text-stroke-color: ${this.normalizeColor(this.stroke)}`,
                'paint-order: stroke',
            ] : [],
            ...this.dropShadow ? [this.dropShadowToCSS()] : [],
            ...this._overrides,
        ].join(';');
    }

    /** Get the font CSS styles from the loaded font, If available. */
    public toGlobalCSS(): string
    {
        return this._fonts.reduce((result, font) => (
            `${result}
            @font-face {
                font-family: "${font.family}";
                src: url('${font.dataSrc}');
                font-weight: ${font.weight};
                font-style: ${font.style};
                font-display: ${font.display};
            }`
        ), this._stylesheet);
    }

    /** Internal stylesheet contents, useful for creating rules for rendering */
    public get stylesheet(): string
    {
        return this._stylesheet;
    }
    public set stylesheet(value: string)
    {
        if (this._stylesheet !== value)
        {
            this._stylesheet = value;
            this.styleID++;
        }
    }

    /**
     * Convert numerical colors into hex-strings
     * @param color
     */
    private normalizeColor(color: any): string
    {
        if (Array.isArray(color))
        {
            color = utils.rgb2hex(color);
        }

        if (typeof color === 'number')
        {
            return utils.hex2string(color);
        }

        return color;
    }

    /** Convert the internal drop-shadow settings to CSS text-shadow */
    private dropShadowToCSS(): string
    {
        let color = this.normalizeColor(this.dropShadowColor);
        const alpha = this.dropShadowAlpha;
        const x = Math.round(Math.cos(this.dropShadowAngle) * this.dropShadowDistance);
        const y = Math.round(Math.sin(this.dropShadowAngle) * this.dropShadowDistance);

        // Append alpha to color
        if (color.startsWith('#') && alpha < 1)
        {
            color += (alpha * 255 | 0).toString(16).padStart(2, '0');
        }

        const position = `${x}px ${y}px`;

        if (this.dropShadowBlur > 0)
        {
            return `text-shadow: ${position} ${this.dropShadowBlur}px ${color}`;
        }

        return `text-shadow: ${position} ${color}`;
    }

    /** Resets all properties to the defaults specified in TextStyle.prototype._default */
    public reset(): void
    {
        Object.assign(this, HTMLTextStyle.defaultOptions);
    }

    /**
     * Called after the image is loaded but before drawing to the canvas.
     * Mostly used to handle Safari's font loading bug.
     * @ignore
     */
    public onBeforeDraw()
    {
        const { fontsDirty: prevFontsDirty } = this;

        this.fontsDirty = false;

        // Safari has a known bug where embedded fonts are not available
        // immediately after the image loads, to compensate we wait an
        // arbitrary amount of time
        // @see https://bugs.webkit.org/show_bug.cgi?id=219770
        if (this.isSafari && this._fonts.length > 0 && prevFontsDirty)
        {
            return new Promise<void>((resolve) => setTimeout(resolve, 100));
        }

        return Promise.resolve();
    }

    /**
     * Proving that Safari is the new IE
     * @ignore
     */
    private get isSafari(): boolean
    {
        const { userAgent } = settings.ADAPTER.getNavigator();

        return (/^((?!chrome|android).)*safari/i).test(userAgent);
    }

    override set fillGradientStops(_value: number[])
    {
        console.warn('[HTMLTextStyle] fillGradientStops is not supported by HTMLText');
    }
    override get fillGradientStops()
    {
        return super.fillGradientStops;
    }

    override set fillGradientType(_value: number)
    {
        console.warn('[HTMLTextStyle] fillGradientType is not supported by HTMLText');
    }
    override get fillGradientType()
    {
        return super.fillGradientType;
    }

    override set miterLimit(_value: number)
    {
        console.warn('[HTMLTextStyle] miterLimit is not supported by HTMLText');
    }
    override get miterLimit()
    {
        return super.miterLimit;
    }

    override set trim(_value: boolean)
    {
        console.warn('[HTMLTextStyle] trim is not supported by HTMLText');
    }
    override get trim()
    {
        return super.trim;
    }

    override set textBaseline(_value: TextStyleTextBaseline)
    {
        console.warn('[HTMLTextStyle] textBaseline is not supported by HTMLText');
    }
    override get textBaseline()
    {
        return super.textBaseline;
    }

    override set leading(_value: number)
    {
        console.warn('[HTMLTextStyle] leading is not supported by HTMLText');
    }
    override get leading()
    {
        return super.leading;
    }

    override set lineJoin(_value: TextStyleLineJoin)
    {
        console.warn('[HTMLTextStyle] lineJoin is not supported by HTMLText');
    }
    override get lineJoin()
    {
        return super.lineJoin;
    }
}