@lightningjs/renderer
Version:
Lightning 3 Renderer
305 lines (276 loc) • 8.99 kB
text/typescript
/*
* 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,
FontLoadOptions,
FontMetrics,
NormalizedFontMetrics,
} from './TextRenderer.js';
import type { Stage } from '../Stage.js';
import { hasZeroWidthSpace } from './Utils.js';
import type { CoreTextNode } from '../CoreTextNode.js';
import { UpdateType } from '../CoreNode.js';
import {
defaultFontMetrics,
normalizeFontMetrics,
} from './TextLayoutEngine.js';
interface CanvasFont {
fontFamily: string;
fontFace?: FontFace;
metrics?: FontMetrics;
}
/**
* Global font set regardless of if run in the main thread or a web worker
*/
// const globalFontSet: FontFaceSet = (resolvedGlobal.document?.fonts ||
// (resolvedGlobal as unknown as { fonts: FontFaceSet }).fonts) as FontFaceSet;
// Global state variables for fontHandler
const fontFamilies: Record<string, FontFace> = {};
const fontLoadPromises = new Map<string, Promise<void>>();
const normalizedMetrics = new Map<string, NormalizedFontMetrics>();
const nodesWaitingForFont: Record<string, CoreTextNode[]> = Object.create(
null,
) as Record<string, CoreTextNode[]>;
const fontCache = new Map<string, CanvasFont>();
let initialized = false;
let context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D;
let measureContext:
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
/**
* Check if a font can be rendered
*/
export const canRenderFont = (): boolean => {
// Canvas can always render any font family (assuming the browser supports it)
return true;
};
const processFontData = (
fontFamily: string,
fontFace?: FontFace,
metrics?: FontMetrics,
) => {
metrics = metrics || defaultFontMetrics;
fontCache.set(fontFamily, {
fontFamily,
fontFace,
metrics,
});
};
/**
* Load a font by providing fontFamily, fontUrl, and optional metrics
*/
export const loadFont = async (
stage: Stage,
options: FontLoadOptions,
): Promise<void> => {
const { fontFamily, fontUrl, metrics } = options;
// If already loaded, return immediately
if (fontCache.has(fontFamily) === true) {
return;
}
const existingPromise = fontLoadPromises.get(fontFamily);
// If already loading, return the existing promise
if (existingPromise !== undefined) {
return existingPromise;
}
const nwff: CoreTextNode[] = (nodesWaitingForFont[fontFamily] = []);
// Create and store the loading promise
const loadPromise = new FontFace(fontFamily, `url(${fontUrl})`)
.load()
.then((loadedFont) => {
stage.platform.addFont(loadedFont);
processFontData(fontFamily, loadedFont, metrics);
fontLoadPromises.delete(fontFamily);
for (let key in nwff) {
nwff[key]!.setUpdateType(UpdateType.Local);
}
delete nodesWaitingForFont[fontFamily];
})
.catch((error) => {
fontLoadPromises.delete(fontFamily);
console.error(`Failed to load font: ${fontFamily}`, error);
throw error;
});
fontLoadPromises.set(fontFamily, loadPromise);
return loadPromise;
};
/**
* Get the font families map for resolving fonts
*/
export const getFontFamilies = (): FontFamilyMap => {
return fontFamilies;
};
/**
* Initialize the global font handler
*/
export const init = (
c: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
mc?: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D,
): void => {
if (initialized === true) {
return;
}
if (c === undefined) {
throw new Error(
'Canvas context is not provided for font handler initialization',
);
}
context = c;
measureContext = mc || c;
// Register the default 'sans-serif' font face
const defaultMetrics: FontMetrics = {
ascender: 800,
descender: -200,
lineGap: 200,
unitsPerEm: 1000,
};
processFontData('sans-serif', undefined, defaultMetrics);
initialized = true;
};
export const type = 'canvas';
/**
* Check if a font is already loaded by font family
*/
export const isFontLoaded = (fontFamily: string): boolean => {
return fontCache.has(fontFamily);
};
/**
* Wait for a font to load
*
* @param fontFamily
* @param node
*/
export const waitingForFont = (fontFamily: string, node: CoreTextNode) => {
if (nodesWaitingForFont[fontFamily] === undefined) {
return;
}
nodesWaitingForFont[fontFamily]![node.id] = node;
};
/**
* Stop waiting for a font to load
*
* @param fontFamily
* @param node
* @returns
*/
export const stopWaitingForFont = (fontFamily: string, node: CoreTextNode) => {
if (nodesWaitingForFont[fontFamily] === undefined) {
return;
}
delete nodesWaitingForFont[fontFamily][node.id];
};
export const getFontMetrics = (
fontFamily: string,
fontSize: number,
): NormalizedFontMetrics => {
const out = normalizedMetrics.get(fontFamily + fontSize);
if (out !== undefined) {
return out;
}
let metrics = fontCache.get(fontFamily)!.metrics;
if (metrics === undefined) {
metrics = calculateFontMetrics(fontFamily, fontSize);
}
return processFontMetrics(fontFamily, fontSize, metrics);
};
export const processFontMetrics = (
fontFamily: string,
fontSize: number,
metrics: FontMetrics,
): NormalizedFontMetrics => {
const label = fontFamily + fontSize;
const normalized = normalizeFontMetrics(metrics, fontSize);
normalizedMetrics.set(label, normalized);
return normalized;
};
export const measureText = (
text: string,
fontFamily: string,
letterSpacing: number,
) => {
if (letterSpacing === 0) {
return measureContext.measureText(text).width;
}
if (hasZeroWidthSpace(text) === false) {
return measureContext.measureText(text).width + letterSpacing * text.length;
}
return text.split('').reduce((acc, char) => {
if (hasZeroWidthSpace(char) === true) {
return acc;
}
return acc + measureContext.measureText(char).width + letterSpacing;
}, 0);
};
/**
* Get the font metrics for a font face.
*
* @remarks
* This function will attempt to grab the explicitly defined metrics from the
* font face first. If the font face does not have metrics defined, it will
* attempt to calculate the metrics using the browser's measureText method.
*
* If the browser does not support the font metrics API, it will use some
* default values.
*
* @param context
* @param fontFace
* @param fontSize
* @returns
*/
export function calculateFontMetrics(
fontFamily: string,
fontSize: number,
): FontMetrics {
// If the font face doesn't have metrics defined, we fallback to using the
// browser's measureText method to calculate take a best guess at the font
// actual font's metrics.
// - fontBoundingBox[Ascent|Descent] is the best estimate but only supported
// in Chrome 87+ (2020), Firefox 116+ (2023), and Safari 11.1+ (2018).
// - It is an estimate as it can vary between browsers.
// - actualBoundingBox[Ascent|Descent] is less accurate and supported in
// Chrome 77+ (2019), Firefox 74+ (2020), and Safari 11.1+ (2018).
// - If neither are supported, we'll use some default values which will
// get text on the screen but likely not be great.
// NOTE: It's been decided not to rely on fontBoundingBox[Ascent|Descent]
// as it's browser support is limited and it also tends to produce higher than
// expected values. It is instead HIGHLY RECOMMENDED that developers provide
// explicit metrics in the font face definition.
const metrics = measureContext.measureText(
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
);
console.warn(
`Font metrics not provided for Canvas Web font ${fontFamily}. ` +
'Using fallback values. It is HIGHLY recommended you use the latest ' +
'version of the Lightning 3 `msdf-generator` tool to extract the default ' +
'metrics for the font and provide them in the Canvas Web font definition.',
);
const ascender =
metrics.fontBoundingBoxAscent ?? metrics.actualBoundingBoxAscent ?? 0;
const descender =
metrics.fontBoundingBoxDescent ?? metrics.actualBoundingBoxDescent ?? 0;
return {
ascender,
descender: -descender,
lineGap:
(metrics.emHeightAscent ?? 0) +
(metrics.emHeightDescent ?? 0) -
(ascender + descender),
unitsPerEm: (metrics.emHeightAscent ?? 0) + (metrics.emHeightDescent ?? 0),
};
}