Source: rendering/renderers/shared/extract/ExtractSystem.ts

import { ExtensionType } from '../../../../extensions/Extensions';
import { Container } from '../../../../scene/container/Container';
import { Texture } from '../texture/Texture';

import type { ColorSource } from '../../../../color/Color';
import type { ICanvas } from '../../../../environment/canvas/ICanvas';
import type { Rectangle } from '../../../../maths/shapes/Rectangle';
import type { Renderer } from '../../types';
import type { System } from '../system/System';
import type { GetPixelsOutput } from '../texture/GenerateCanvas';
import type { GenerateTextureOptions } from './GenerateTextureSystem';

const imageTypes = {
    png: 'image/png',
    jpg: 'image/jpeg',
    webp: 'image/webp',
};

type Formats = keyof typeof imageTypes;

/**
 * Options for creating an image from a renderer.
 * @memberof rendering
 */
export interface ImageOptions
{
    /** The format of the image. */
    format?: Formats;
    /** The quality of the image. */
    quality?: number;
}

/**
 * Options for extracting content from a renderer.
 * @memberof rendering
 */
export interface BaseExtractOptions
{
    /** The target to extract. */
    target: Container | Texture;
    /** The region of the target to extract. */
    frame?: Rectangle;
    /** The resolution of the extracted content. */
    resolution?: number;
    /** The color used to clear the extracted content. */
    clearColor?: ColorSource;
    /** Whether to enable anti-aliasing. This may affect performance. */
    antialias?: boolean;
}
/**
 * Options for extracting an HTMLImage from the renderer.
 * @memberof rendering
 */
export type ExtractImageOptions = BaseExtractOptions & ImageOptions;
/**
 * Options for extracting and downloading content from a renderer.
 * @memberof rendering
 */
export type ExtractDownloadOptions = BaseExtractOptions & {
    /** The filename to use when downloading the content. */
    filename: string;
};
/**
 * Options for extracting content from a renderer.
 * @memberof rendering
 */
export type ExtractOptions = BaseExtractOptions | ExtractImageOptions | ExtractDownloadOptions;

/**
 * This class provides renderer-specific plugins for exporting content from a renderer.
 * For instance, these plugins can be used for saving an Image, Canvas element or for exporting the raw image data (pixels).
 *
 * Do not instantiate these plugins directly. It is available from the `renderer.extract` property.
 * @example
 * import { Application, Graphics } from 'pixi.js';
 *
 * // Create a new application (extract will be auto-added to renderer)
 * const app = new Application();
 * await app.init();
 *
 * // Draw a red circle
 * const graphics = new Graphics()
 *     .circle(0, 0, 50);
 *     .fill(0xFF0000)
 *
 * // Render the graphics as an HTMLImageElement
 * const image = await app.renderer.extract.image(graphics);
 * document.body.appendChild(image);
 * @memberof rendering
 */
export class ExtractSystem implements System
{
    /** @ignore */
    public static extension = {
        type: [
            ExtensionType.WebGLSystem,
            ExtensionType.WebGPUSystem,
        ],
        name: 'extract',
    } as const;

    /** Default options for creating an image. */
    public static defaultImageOptions: ImageOptions = {
        /** The format of the image. */
        format: 'png' as Formats,
        /** The quality of the image. */
        quality: 1,
    };

    private _renderer: Renderer;

    /** @param renderer - The renderer this System works for. */
    constructor(renderer: Renderer)
    {
        this._renderer = renderer;
    }

    private _normalizeOptions<T extends ExtractOptions>(
        options: ExtractImageOptions | Container | Texture,
        defaults: Partial<T> = {},
    ): T
    {
        if (options instanceof Container || options instanceof Texture)
        {
            return {
                target: options,
                ...defaults
            } as T;
        }

        return {
            ...defaults,
            ...options,
        } as T;
    }

    /**
     * Will return a HTML Image of the target
     * @param options - The options for creating the image, or the target to extract
     * @returns - HTML Image of the target
     */
    public async image(options: ExtractImageOptions | Container | Texture): Promise<HTMLImageElement>
    {
        const image = new Image();

        image.src = await this.base64(options);

        return image;
    }

    /**
     * Will return a base64 encoded string of this target. It works by calling
     * `Extract.canvas` and then running toDataURL on that.
     * @param options - The options for creating the image, or the target to extract
     */
    public async base64(options: ExtractImageOptions | Container | Texture): Promise<string>
    {
        options = this._normalizeOptions<ExtractImageOptions>(
            options,
            ExtractSystem.defaultImageOptions
        );

        const { format, quality } = options;

        const canvas = this.canvas(options);

        if (canvas.toBlob !== undefined)
        {
            return new Promise<string>((resolve, reject) =>
            {
                // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
                canvas.toBlob!((blob) =>
                {
                    if (!blob)
                    {
                        reject(new Error('ICanvas.toBlob failed!'));

                        return;
                    }

                    const reader = new FileReader();

                    reader.onload = () => resolve(reader.result as string);
                    reader.onerror = reject;
                    reader.readAsDataURL(blob);
                }, imageTypes[format], quality);
            });
        }
        if (canvas.toDataURL !== undefined)
        {
            return canvas.toDataURL(imageTypes[format], quality);
        }
        if (canvas.convertToBlob !== undefined)
        {
            const blob = await canvas.convertToBlob({ type: imageTypes[format], quality });

            return new Promise<string>((resolve, reject) =>
            {
                const reader = new FileReader();

                reader.onload = () => resolve(reader.result as string);
                reader.onerror = reject;
                reader.readAsDataURL(blob);
            });
        }

        throw new Error('Extract.base64() requires ICanvas.toDataURL, ICanvas.toBlob, '
            + 'or ICanvas.convertToBlob to be implemented');
    }

    /**
     * Creates a Canvas element, renders this target to it and then returns it.
     * @param options - The options for creating the canvas, or the target to extract
     * @returns - A Canvas element with the texture rendered on.
     */
    public canvas(options: ExtractOptions | Container | Texture): ICanvas
    {
        options = this._normalizeOptions(options);

        const target = options.target;

        const renderer = this._renderer;

        if (target instanceof Texture)
        {
            return renderer.texture.generateCanvas(target);
        }

        const texture = renderer.textureGenerator.generateTexture(options as GenerateTextureOptions);

        const canvas = renderer.texture.generateCanvas(texture);

        texture.destroy();

        return canvas;
    }

    /**
     * Will return a one-dimensional array containing the pixel data of the entire texture in RGBA
     * order, with integer values between 0 and 255 (included).
     * @param options - The options for extracting the image, or the target to extract
     * @returns - One-dimensional array containing the pixel data of the entire texture
     */
    public pixels(options: ExtractOptions | Container | Texture): GetPixelsOutput
    {
        options = this._normalizeOptions(options);

        const target = options.target;

        const renderer = this._renderer;
        const texture = target instanceof Texture
            ? target
            : renderer.textureGenerator.generateTexture(options as GenerateTextureOptions);

        const pixelInfo = renderer.texture.getPixels(texture);

        if (target instanceof Container)
        {
            // destroy generated texture
            texture.destroy();
        }

        return pixelInfo;
    }

    /**
     * Will return a texture of the target
     * @param options - The options for creating the texture, or the target to extract
     * @returns - A texture of the target
     */
    public texture(options: ExtractOptions | Container | Texture): Texture
    {
        options = this._normalizeOptions(options);

        if (options.target instanceof Texture) return options.target;

        return this._renderer.textureGenerator.generateTexture(options as GenerateTextureOptions);
    }

    /**
     * Will extract a HTMLImage of the target and download it
     * @param options - The options for downloading and extracting the image, or the target to extract
     */
    public download(options: ExtractDownloadOptions | Container | Texture)
    {
        options = this._normalizeOptions<ExtractDownloadOptions>(options);

        const canvas = this.canvas(options);

        const link = document.createElement('a');

        link.download = options.filename ?? 'image.png';
        link.href = canvas.toDataURL('image/png');
        document.body.appendChild(link);
        link.click();
        document.body.removeChild(link);
    }

    /**
     * Logs the target to the console as an image. This is a useful way to debug what's happening in the renderer.
     * @param options - The options for logging the image, or the target to log
     */
    public log(options: (ExtractOptions & {width?: number}) | Container | Texture)
    {
        const width = options.width ?? 200;

        options = this._normalizeOptions(options);

        const canvas = this.canvas(options);

        const base64 = canvas.toDataURL();

        // eslint-disable-next-line no-console
        console.log(`[Pixi Texture] ${canvas.width}px ${canvas.height}px`);

        const style = [
            'font-size: 1px;',
            `padding: ${width}px ${300}px;`,
            `background: url(${base64}) no-repeat;`,
            'background-size: contain;',
        ].join(' ');

        // eslint-disable-next-line no-console
        console.log('%c ', style);
    }

    public destroy(): void
    {
        this._renderer = null as any as Renderer;
    }
}