UNPKG

@lightningjs/renderer

Version:
518 lines (458 loc) 14.4 kB
/* * If not stated otherwise in this file or this component's LICENSE file the * following copyright and licenses apply: * * Copyright 2025 Comcast Cable Communications Management, LLC. * * Licensed under the Apache License, Version 2.0 (the License); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import type { FontFamilyMap, FontMetrics, NormalizedFontMetrics, TrProps, FontLoadOptions, } from './TextRenderer.js'; import type { ImageTexture } from '../textures/ImageTexture.js'; import type { Stage } from '../Stage.js'; /** * SDF Font Data structure matching msdf-bmfont-xml output */ export interface SdfFontData { pages: string[]; chars: Array<{ id: number; char: string; x: number; y: number; width: number; height: number; xoffset: number; yoffset: number; xadvance: number; page: number; chnl: number; }>; kernings: Array<{ first: number; second: number; amount: number; }>; info: { face: string; size: number; bold: number; italic: number; charset: string[]; unicode: number; stretchH: number; smooth: number; aa: number; padding: [number, number, number, number]; // [up, right, down, left] spacing: [number, number]; // [horizontal, vertical] outline: number; }; common: { lineHeight: number; base: number; scaleW: number; scaleH: number; pages: number; packed: number; alphaChnl: number; redChnl: number; greenChnl: number; blueChnl: number; }; distanceField: { // msdf-bmfont-xml uses the string 'sdf' for single-channel SDF. fieldType: 'sdf' | 'msdf'; distanceRange: number; }; lightningMetrics?: FontMetrics; } /** * @typedef {Object} SdfGlyph * @property {number} id - Glyph ID * @property {string} char - Character * @property {number} x - Atlas x position * @property {number} y - Atlas y position * @property {number} width - Glyph width * @property {number} height - Glyph height * @property {number} xoffset - X offset * @property {number} yoffset - Y offset * @property {number} xadvance - Character advance width * @property {number} page - Page number * @property {number} chnl - Channel */ /** * @typedef {Object} KerningTable * Fast lookup table for kerning values */ type KerningTable = Record< number, Record<number, number | undefined> | undefined >; /** * @typedef {Object} SdfFontCache * Cached font data for performance */ interface SdfFontCache { data: SdfFontData; glyphMap: Map<number, SdfFontData['chars'][0]>; kernings: KerningTable; atlasTexture: ImageTexture; metrics: NormalizedFontMetrics; maxCharHeight: number; } //global state variables for SdfFontHandler const fontCache: Record<string, SdfFontCache> = Object.create(null); const loadedFonts = new Set<string>(); const fontLoadPromises = new Map<string, Promise<void>>(); let initialized = false; /** * Normalize font metrics to be in the range of 0 to 1 */ const normalizeMetrics = (metrics: FontMetrics): NormalizedFontMetrics => ({ ascender: metrics.ascender / metrics.unitsPerEm, descender: metrics.descender / metrics.unitsPerEm, lineGap: metrics.lineGap / metrics.unitsPerEm, }); /** * Build kerning lookup table for fast access * @param {Array} kernings - Kerning data from font * @returns {KerningTable} Optimized kerning lookup table */ const buildKerningTable = (kernings: SdfFontData['kernings']): KerningTable => { const kerningTable: KerningTable = {}; let i = 0; const length = kernings.length; while (i < length) { const kerning = kernings[i]; i++; if (kerning === undefined) { continue; } const second = kerning.second; let firsts = kerningTable[second]; if (firsts === undefined) { firsts = {}; kerningTable[second] = firsts; } firsts[kerning.first] = kerning.amount; } return kerningTable; }; /** * Build glyph map from font data for fast character lookup * @param {Array} chars - Character data from font * @returns {Map} Glyph map for character to glyph lookup */ const buildGlyphMap = ( chars: SdfFontData['chars'], ): Map<number, SdfFontData['chars'][0]> => { const glyphMap = new Map<number, SdfFontData['chars'][0]>(); let maxCharHeight = 0; let i = 0; const length = chars.length; while (i < length) { const glyph = chars[i]; i++; if (glyph === undefined) { continue; } glyphMap.set(glyph.id, glyph); const charHeight = glyph.yoffset + glyph.height; if (charHeight > maxCharHeight) { maxCharHeight = charHeight; } } return glyphMap; }; /** * Process font data and create optimized cache entry * @param {string} fontFamily - Font family name * @param {SdfFontData} fontData - Raw font data * @param {ImageTexture} atlasTexture - Atlas texture * @param {FontMetrics} metrics - Font metrics */ const processFontData = ( fontFamily: string, fontData: SdfFontData, atlasTexture: ImageTexture, metrics?: FontMetrics, ): void => { // Build optimized data structures const glyphMap = buildGlyphMap(fontData.chars); const kernings = buildKerningTable(fontData.kernings); // Calculate max char height let maxCharHeight = 0; let i = 0; const length = fontData.chars.length; while (i < length) { const glyph = fontData.chars[i]; if (glyph !== undefined) { const charHeight = glyph.yoffset + glyph.height; if (charHeight > maxCharHeight) { maxCharHeight = charHeight; } } i++; } // Determine metrics let normalizedMetrics: NormalizedFontMetrics; if (metrics !== undefined) { normalizedMetrics = normalizeMetrics(metrics); } else if (fontData.lightningMetrics !== undefined) { normalizedMetrics = normalizeMetrics(fontData.lightningMetrics); } else { console.warn( `Font metrics not found for SDF font ${fontFamily}. ` + 'Make sure you are using the latest version of the Lightning ' + '3 msdf-generator tool to generate your SDF fonts. Using default metrics.', ); // Use default metrics normalizedMetrics = { ascender: 0.8, descender: -0.2, lineGap: 0.2, }; } // Cache processed data fontCache[fontFamily] = { data: fontData, glyphMap, kernings, atlasTexture, metrics: normalizedMetrics, maxCharHeight, }; }; /** * Check if the SDF font handler can render a font * @param {TrProps} trProps - Text rendering properties * @returns {boolean} True if the font can be rendered */ export const canRenderFont = (trProps: TrProps): boolean => { return isFontLoaded(trProps.fontFamily); }; /** * Load SDF font from JSON + PNG atlas * @param {Object} options - Font loading options * @param {string} options.fontFamily - Font family name * @param {string} options.fontUrl - JSON font data URL (atlasDataUrl) * @param {string} options.atlasUrl - PNG atlas texture URL * @param {FontMetrics} options.metrics - Optional font metrics */ export const loadFont = async ( stage: Stage, options: FontLoadOptions, ): Promise<void> => { const { fontFamily, atlasUrl, atlasDataUrl, metrics } = options; // Early return if already loaded if (loadedFonts.has(fontFamily) === true) { return; } // Early return if already loading const existingPromise = fontLoadPromises.get(fontFamily); if (existingPromise !== undefined) { return existingPromise; } if (atlasDataUrl === undefined) { throw new Error( `Atlas data URL must be provided for SDF font: ${fontFamily}`, ); } // Create loading promise const loadPromise = (async (): Promise<void> => { // Load font JSON data const response = await fetch(atlasDataUrl); if (!response.ok) { throw new Error(`Failed to load font data: ${response.statusText}`); } const fontData = (await response.json()) as SdfFontData; if (!fontData || !fontData.chars) { throw new Error('Invalid SDF font data format'); } // Atlas texture should be provided externally if (!atlasUrl) { throw new Error('Atlas texture must be provided for SDF fonts'); } // Wait for atlas texture to load return new Promise<void>((resolve, reject) => { // create new atlas texture using ImageTexture const atlasTexture = stage.txManager.createTexture('ImageTexture', { src: atlasUrl, premultiplyAlpha: false, }); atlasTexture.preventCleanup = true; // Prevent automatic cleanup if (atlasTexture.state === 'loaded') { // If already loaded, process immediately processFontData(fontFamily, fontData, atlasTexture, metrics); loadedFonts.add(fontFamily); fontLoadPromises.delete(fontFamily); return resolve(); } atlasTexture.on('loaded', () => { // Process and cache font data processFontData(fontFamily, fontData, atlasTexture, metrics); // Mark as loaded loadedFonts.add(fontFamily); fontLoadPromises.delete(fontFamily); resolve(); }); atlasTexture.on('failed', (error: Error) => { // Cleanup on error fontLoadPromises.delete(fontFamily); if (fontCache[fontFamily]) { delete fontCache[fontFamily]; } console.error(`Failed to load SDF font: ${fontFamily}`, error); reject(error); }); atlasTexture.setRenderableOwner(stage, true); }); })(); fontLoadPromises.set(fontFamily, loadPromise); return loadPromise; }; /** * Get the font families map for resolving fonts */ export const getFontFamilies = (): FontFamilyMap => { const families: FontFamilyMap = {}; // SDF fonts don't use the traditional FontFamilyMap structure // Return empty map since SDF fonts are handled differently return families; }; /** * Initialize the SDF font handler */ export const init = ( c?: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D, ): void => { if (initialized === true) { return; } initialized = true; }; export const type = 'sdf'; /** * Check if a font is already loaded by font family */ export const isFontLoaded = (fontFamily: string): boolean => { return loadedFonts.has(fontFamily); }; /** * Get normalized font metrics for a font family */ export const getFontMetrics = ( fontFamily: string, // eslint-disable-next-line @typescript-eslint/no-unused-vars fontSize: number, ): NormalizedFontMetrics => { const cache = fontCache[fontFamily]; return cache ? cache.metrics : { ascender: 0, descender: 0, lineGap: 0 }; }; /** * Set font metrics for a font family */ export const setFontMetrics = ( fontFamily: string, metrics: NormalizedFontMetrics, ): void => { const cache = fontCache[fontFamily]; if (cache !== undefined) { cache.metrics = metrics; } }; /** * Get glyph data for a character in a specific font * @param {string} fontFamily - Font family name * @param {number} codepoint - Character codepoint * @returns {Object|null} Glyph data or null if not found */ export const getGlyph = ( fontFamily: string, codepoint: number, ): SdfFontData['chars'][0] | null => { const cache = fontCache[fontFamily]; if (cache === undefined) return null; return cache.glyphMap.get(codepoint) || cache.glyphMap.get(63) || null; // 63 = '?' }; /** * Get kerning value between two glyphs * @param {string} fontFamily - Font family name * @param {number} firstGlyph - First glyph ID * @param {number} secondGlyph - Second glyph ID * @returns {number} Kerning value or 0 */ export const getKerning = ( fontFamily: string, firstGlyph: number, secondGlyph: number, ): number => { const cache = fontCache[fontFamily]; if (cache === undefined) return 0; const seconds = cache.kernings[secondGlyph]; return seconds ? seconds[firstGlyph] || 0 : 0; }; /** * Get atlas texture for a font family * @param {string} fontFamily - Font family name * @returns {ImageTexture|null} Atlas texture or null */ export const getAtlas = (fontFamily: string): ImageTexture | null => { const cache = fontCache[fontFamily]; return cache !== undefined ? cache.atlasTexture : null; }; /** * Get font data for a font family * @param {string} fontFamily - Font family name * @returns {SdfFontData|null} Font data or null */ export const getFontData = (fontFamily: string): SdfFontData | null => { const cache = fontCache[fontFamily]; return cache !== undefined ? cache.data : null; }; /** * Get maximum character height for a font family * @param {string} fontFamily - Font family name * @returns {number} Max character height or 0 */ export const getMaxCharHeight = (fontFamily: string): number => { const cache = fontCache[fontFamily]; return cache !== undefined ? cache.maxCharHeight : 0; }; /** * Get all loaded font families * @returns {string[]} Array of font family names */ export const getLoadedFonts = (): string[] => { return Array.from(loadedFonts); }; /** * Unload a font and free resources * @param {string} fontFamily - Font family name */ export const unloadFont = (fontFamily: string): void => { const cache = fontCache[fontFamily]; if (cache !== undefined) { // Free texture if needed if (typeof cache.atlasTexture.free === 'function') { cache.atlasTexture.free(); } delete fontCache[fontFamily]; loadedFonts.delete(fontFamily); } };