utils/FontAtlas.tstypescript
import { Deferred } from "../Deferred";
import { Rectangle } from "../Rectangle";
import { TextureAtlasNode } from "./TextureAtlas";
import type { ImageSource, WebGLTextureExt } from "../webgl/Handler";
import { Handler } from "../webgl/Handler";
import type { HTMLImageElementExt } from "./ImagesCacheManager";

//@todo: get the value from shader module
const MAX_SIZE = 11;

interface IChar {
    id: number;
    char: string;
    width: number;
    height: number;
    x: number;
    y: number;
    chnl: number;
    index: number;
    page: number;
    xadvance: number;
    xoffset: number;
    yoffset: number;
}

interface IKerning {
    first: number;
    second: number;
    amount: number;
}

interface IFontBmParams {
    common: {
        scaleH: number;
        scaleW: number;
    };
    info: {
        size: number;
    };
    distanceField: {
        distanceRange: number;
        isMtsdf: boolean;
    };
    glyphs: IChar[];
    kernings: IKerning[];
}

interface IMSDFAtlasBounds {
    left: number;
    bottom: number;
    right: number;
    top: number;
}

interface IMSDFGlyph {
    index: string;
    unicode?: number | string;
    advance: number;
    planeBounds: IMSDFAtlasBounds;
    atlasBounds: IMSDFAtlasBounds;
}

interface IMSDFKerning {
    index1: number;
    index2: number;
    advance: number;
}

export interface IMSDFAtlasParams {
    atlas: {
        type: string;
        distanceRange: number;
        distanceRangeMiddle: number;
        size: number;
        width: number;
        height: number;
        yOrigin: "top" | "bottom";
    };
    metrics: {
        emSize: number;
        lineHeight: number;
        ascender: number;
        descender: number;
        underlineY: number;
        underlineThickness: number;
    };
    glyphs: IMSDFGlyph[];
    kerning: IMSDFKerning[];
}

class FontTextureAtlas {
    public width: number;
    public height: number;
    public gliphSize: number;
    public distanceRange: number;
    public isMtsdf: boolean;
    public sourceImage: HTMLImageElementExt | null;
    public nodes: Map<number, FontTextureAtlasNode>;
    public kernings: Record<number, Record<number, number>>;

    constructor() {
        this.width = 0;
        this.height = 0;
        this.gliphSize = 0;
        this.distanceRange = 0;
        this.isMtsdf = false;
        this.sourceImage = null;
        this.nodes = new Map<number, FontTextureAtlasNode>();
        this.kernings = {};
    }

    public get(key: number): FontTextureAtlasNode | undefined {
        return this.nodes.get(key);
    }

    public createTexture(img?: HTMLImageElementExt | null) {
        this.sourceImage = img || null;
    }
}

interface IMetrics extends IChar {
    nChar: string;
    nCode: number;
    nWidth: number;
    nHeight: number;
    nAdvance: number;
    nXOffset: number;
    nYOffset: number;
}

class FontTextureAtlasNode extends TextureAtlasNode {
    public metrics: IMetrics;
    public emptySize: number;

    constructor(rect: Rectangle, texCoords: number[]) {
        super(rect, texCoords);
        this.emptySize = 1;
        this.metrics = {
            id: 0,
            char: "",
            width: 0,
            height: 0,
            x: 0,
            y: 0,
            chnl: 0,
            index: 0,
            page: 0,
            xadvance: 0,
            xoffset: 0,
            yoffset: 0,

            nChar: "",
            nCode: 0,
            nWidth: 0,
            nHeight: 0,
            nAdvance: 0,
            nXOffset: 0,
            nYOffset: 0
        };
    }
}

class FontAtlas {
    public atlasesArr: FontTextureAtlas[];
    public samplerArr: Uint32Array;
    public sdfParamsArr: Float32Array;
    public textureArray: WebGLTextureExt | null;
    public catalogSrc: string;

    protected atlasIndexes: Record<string, number>;
    protected atlasIndexesDeferred: Record<string, Deferred<number>>;
    protected tokenImageSize: number;
    protected _handler: Handler | null;
    protected _textureArrayWidth: number;
    protected _textureArrayHeight: number;
    protected _textureArrayMismatchWarningShown: boolean;
    protected _fontLoadWarningShown: Record<string, boolean>;

    constructor(catalogSrc?: string) {
        this.atlasesArr = [];
        this.atlasIndexes = {};
        this.atlasIndexesDeferred = {};
        this.tokenImageSize = 64;
        this.samplerArr = new Uint32Array(MAX_SIZE);
        this.sdfParamsArr = new Float32Array(MAX_SIZE * 4);
        this.textureArray = null;
        this._handler = null;
        this._textureArrayWidth = 0;
        this._textureArrayHeight = 0;
        this._textureArrayMismatchWarningShown = false;
        this._fontLoadWarningShown = {};
        this.catalogSrc = catalogSrc || "./";
    }

    public assignHandler(handler: Handler) {
        this._handler = handler;
    }

    public getFontIndex(face: string): Promise<number> {
        let fullName = this.getFullIndex(face);

        // Try to load font from the directory
        if (this.atlasIndexes[fullName] === undefined) {
            this.loadFont(face, this.catalogSrc, `${face}.json`);
        }

        if (!this.atlasIndexesDeferred[fullName]) {
            this.atlasIndexesDeferred[fullName] = new Deferred<number>();
        }
        return this.atlasIndexesDeferred[fullName].promise;
    }

    public getFullIndex(face: string): string {
        return face.trim().toLowerCase();
    }

    protected _normalizeMsdfAtlasParams(data: IMSDFAtlasParams, atlasUrl?: string): IFontBmParams {
        const s = data.atlas.size || 1;
        const atlasType = (data.atlas.type || "").toLowerCase();
        const isMtsdf = atlasType === "mtsdf";
        const yOrigin = data.atlas.yOrigin || "bottom";
        const isTopOrigin = yOrigin === "top";
        const glyphs: IChar[] = [];

        for (let i = 0; i < data.glyphs.length; i++) {
            const gi = data.glyphs[i];
            const rawGlyphCode =
                gi.unicode != undefined ? Number(gi.unicode) : gi.index != undefined ? Number(gi.index) : i;
            const glyphIndex = Number.isFinite(rawGlyphCode) ? rawGlyphCode : i;

            let x = 0;
            let y = 0;
            let width = 0;
            let height = 0;
            if (gi.atlasBounds) {
                width = gi.atlasBounds.right - gi.atlasBounds.left;
                x = gi.atlasBounds.left;
                if (isTopOrigin) {
                    height = gi.atlasBounds.bottom - gi.atlasBounds.top;
                    y = gi.atlasBounds.top;
                } else {
                    height = gi.atlasBounds.top - gi.atlasBounds.bottom;
                    y = data.atlas.height - gi.atlasBounds.top;
                }
            }

            let xoffset = 0;
            let yoffset = 0;
            if (gi.planeBounds) {
                let planeTop: number;
                let planeBottom: number;
                if (isTopOrigin) {
                    planeTop = -gi.planeBounds.top;
                    planeBottom = -gi.planeBounds.bottom;
                } else {
                    planeTop = gi.planeBounds.top;
                    planeBottom = gi.planeBounds.bottom;
                }
                width = (gi.planeBounds.right - gi.planeBounds.left) * s;
                height = (planeTop - planeBottom) * s;
                xoffset = gi.planeBounds.left * s;
                yoffset = (1.0 - planeTop) * s;
            }

            const char = glyphIndex <= 0x10ffff ? String.fromCodePoint(glyphIndex) : "";

            glyphs.push({
                id: glyphIndex,
                index: glyphIndex,
                char,
                width,
                height,
                x,
                y,
                chnl: 15,
                page: 0,
                xadvance: gi.advance * s,
                xoffset,
                yoffset
            });
        }

        const kernings: IKerning[] = [];
        if (data.kerning) {
            for (let i = 0; i < data.kerning.length; i++) {
                const ki = data.kerning[i];
                const first = ki.index1;
                const second = ki.index2;
                kernings.push({
                    first,
                    second,
                    amount: ki.advance * s
                });
            }
        }

        return {
            common: {
                scaleH: data.atlas.height,
                scaleW: data.atlas.width
            },
            info: {
                size: s
            },
            distanceField: {
                distanceRange: data.atlas.distanceRange,
                isMtsdf
            },
            glyphs,
            kernings
        };
    }

    protected _applyFontDataToAtlas(atlas: FontTextureAtlas, data: IFontBmParams, index: number = 0) {
        let glyphs = data.glyphs;

        atlas.height = data.common.scaleH;
        atlas.width = data.common.scaleW;
        atlas.gliphSize = data.info.size;
        atlas.distanceRange = data.distanceField.distanceRange;
        atlas.isMtsdf = data.distanceField.isMtsdf;

        let w = atlas.width,
            h = atlas.height,
            s = atlas.gliphSize;

        this.sdfParamsArr[index * 4] = w;
        this.sdfParamsArr[index * 4 + 1] = h;
        // z is a shader flag: 1.0 for MTSDF atlas (alpha contains true SDF), 0.0 otherwise.
        this.sdfParamsArr[index * 4 + 2] = atlas.isMtsdf ? 1.0 : 0.0;
        this.sdfParamsArr[index * 4 + 3] = atlas.distanceRange;

        atlas.nodes.clear();

        for (let i = 0; i < glyphs.length; i++) {
            let ci = glyphs[i];

            let r = new Rectangle(ci.x, ci.y, ci.x + ci.width, ci.y + ci.height);

            let tc = new Array(12);

            tc[0] = r.left / w;
            tc[1] = r.top / h;

            tc[2] = r.left / w;
            tc[3] = r.bottom / h;

            tc[4] = r.right / w;
            tc[5] = r.bottom / h;

            tc[6] = r.right / w;
            tc[7] = r.bottom / h;

            tc[8] = r.right / w;
            tc[9] = r.top / h;

            tc[10] = r.left / w;
            tc[11] = r.top / h;

            let taNode = new FontTextureAtlasNode(r, tc);
            const ciNorm = ci.char.normalize("NFKC");
            const ciCode = ciNorm.codePointAt(0) ?? ci.id;

            //taNode.metrics = ci;

            let m = taNode.metrics;

            m.id = ci.id;
            m.char = ci.char;
            m.width = ci.width;
            m.height = ci.height;
            m.x = ci.x;
            m.y = ci.y;
            m.chnl = ci.chnl;
            m.index = ci.index;
            m.page = ci.page;
            m.xadvance = ci.xadvance;
            m.xoffset = ci.xoffset;
            m.yoffset = ci.yoffset;

            m.nChar = ciNorm;
            m.nCode = ciCode;
            m.nWidth = taNode.metrics.width / s;
            m.nHeight = taNode.metrics.height / s;
            m.nAdvance = taNode.metrics.xadvance / s;
            m.nXOffset = taNode.metrics.xoffset / s;
            m.nYOffset = 1.0 - taNode.metrics.yoffset / s;

            taNode.emptySize = 1;

            atlas.nodes.set(ciCode, taNode);
        }

        atlas.kernings = {};

        for (let i = 0; i < data.kernings.length; i++) {
            let ki = data.kernings[i];

            let first = ki.first,
                second = ki.second;

            if (!atlas.kernings[first]) {
                atlas.kernings[first] = {};
            }

            atlas.kernings[first][second] = ki.amount / s;
        }
    }

    public initFont(faceName: string, dataJson: IMSDFAtlasParams, imageBase64: string) {
        let index = this.atlasesArr.length;
        let fullName = this.getFullIndex(faceName);

        this.atlasIndexes[fullName] = index;

        let def = this.atlasIndexesDeferred[fullName];
        if (!def) {
            def = this.atlasIndexesDeferred[fullName] = new Deferred<number>();
        }

        this.samplerArr[this.atlasesArr.length] = index;

        // TODO: FontTextureAtlas();
        let atlas = new FontTextureAtlas();

        atlas.height = 0;
        atlas.width = 0;
        atlas.gliphSize = 0;
        atlas.distanceRange = 0;
        atlas.isMtsdf = false;
        atlas.kernings = {};

        this.atlasesArr[index] = atlas;

        this._applyFontDataToAtlas(atlas, this._normalizeMsdfAtlasParams(dataJson), index);

        let img = new Image();
        img.onload = () => {
            this._createTexture(atlas, img);
            def.resolve(index);
        };
        img.src = imageBase64;
    }

    protected _createTexture(atlas: FontTextureAtlas, img: HTMLImageElementExt) {
        atlas.createTexture(img);
        this._updateTextureArrayLayer(atlas);
        this._handler && (this._handler.needRedraw = true);
    }

    protected _ensureTextureArray(width: number, height: number): boolean {
        if (!this._handler || !this._handler.isWebGl2() || !this._handler.gl) {
            return false;
        }

        if (!this.textureArray) {
            let gl = this._handler.gl;
            this.textureArray = gl.createTexture() as WebGLTextureExt;
            this._textureArrayWidth = width;
            this._textureArrayHeight = height;

            gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.textureArray);
            gl.texImage3D(gl.TEXTURE_2D_ARRAY, 0, gl.RGBA, width, height, MAX_SIZE, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
            gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
            gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
            gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
            gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
        }

        const isSameSize = this._textureArrayWidth === width && this._textureArrayHeight === height;
        if (!isSameSize && !this._textureArrayMismatchWarningShown) {
            this._textureArrayMismatchWarningShown = true;
            console.warn("FontAtlas: all fonts must have identical atlas dimensions for sampler2DArray labels.");
        }
        return isSameSize;
    }

    protected _updateTextureArrayLayer(atlas: FontTextureAtlas) {
        if (!this._handler || !this._handler.gl || !this._handler.isWebGl2()) {
            return;
        }

        let index = this.atlasesArr.indexOf(atlas);
        if (index === -1 || index >= MAX_SIZE) {
            return;
        }

        if (!this._ensureTextureArray(atlas.width, atlas.height)) {
            return;
        }

        let gl = this._handler.gl;
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.textureArray);
        gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
        gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.NONE);
        const uploadSource: ImageSource = atlas.sourceImage as ImageSource;
        gl.texSubImage3D(
            gl.TEXTURE_2D_ARRAY,
            0,
            0,
            0,
            index,
            atlas.width,
            atlas.height,
            1,
            gl.RGBA,
            gl.UNSIGNED_BYTE,
            uploadSource
        );
        gl.pixelStorei(gl.UNPACK_COLORSPACE_CONVERSION_WEBGL, gl.BROWSER_DEFAULT_WEBGL);
        gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, false);
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, null);
    }

    public loadFont(faceName: string, srcDir: string, atlasUrl: string) {
        let index = this.atlasesArr.length;
        let fullName = this.getFullIndex(faceName);
        const fontJsonUrl = `${srcDir}/${atlasUrl}`;

        this.atlasIndexes[fullName] = index;

        let def = this.atlasIndexesDeferred[fullName];
        if (!def) {
            def = this.atlasIndexesDeferred[fullName] = new Deferred<number>();
        }

        this.samplerArr[this.atlasesArr.length] = index;

        let atlas = new FontTextureAtlas();

        atlas.height = 0;
        atlas.width = 0;
        atlas.gliphSize = 0;
        atlas.distanceRange = 0;
        atlas.isMtsdf = false;
        atlas.kernings = {};

        this.atlasesArr[index] = atlas;

        fetch(fontJsonUrl)
            .then((response: Response) => {
                if (!response.ok) {
                    throw Error(`Unable to load "${fontJsonUrl}"`);
                }
                return response.json();
            })
            .then((rawData: IMSDFAtlasParams) => {
                const data = this._normalizeMsdfAtlasParams(rawData, atlasUrl);
                this._applyFontDataToAtlas(atlas, data, index);

                let img = new Image();
                img.onload = () => {
                    this._createTexture(atlas, img);
                    def.resolve(index);
                };

                const atlasImageUrl = `${atlasUrl.slice(0, atlasUrl.length - 5)}.png`;
                img.src = `${srcDir}/${atlasImageUrl}`;
                img.crossOrigin = "Anonymous";
            })
            .catch((err) => {
                if (!this._fontLoadWarningShown[fullName]) {
                    this._fontLoadWarningShown[fullName] = true;
                    console.warn(
                        `FontAtlas: font "${faceName}" not found or invalid (${fontJsonUrl}). Labels using this font may not render.`,
                        err
                    );
                }
                // Keep promise resolved to prevent unhandled rejections in label update loops.
                def.resolve(index);
                return { status: "error", msg: err.toString() };
            });
    }
}

export { FontAtlas };