@lightningtv/renderer
Version:
Lightning 3 Renderer
397 lines • 15.8 kB
JavaScript
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2023 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 { EventEmitter } from '../../../common/EventEmitter.js';
import { assertTruthy } from '../../../utils.js';
import { getNormalizedRgbaComponents, getNormalizedAlphaComponent, } from '../../lib/utils.js';
import {} from '../TrFontManager.js';
import { WebTrFontFace } from '../font-face-types/WebTrFontFace.js';
import { LightningTextTextureRenderer, } from './LightningTextTextureRenderer.js';
import { TextRenderer, } from './TextRenderer.js';
const resolvedGlobal = typeof self === 'undefined' ? globalThis : self;
/**
* Global font set regardless of if run in the main thread or a web worker
*/
const globalFontSet = (resolvedGlobal.document?.fonts ||
resolvedGlobal.fonts);
function getFontCssString(props) {
const { fontFamily, fontStyle, fontWeight, fontStretch, fontSize } = props;
return [fontStyle, fontWeight, fontStretch, `${fontSize}px`, fontFamily].join(' ');
}
export class CanvasTextRenderer extends TextRenderer {
canvas;
context;
/**
* Font family map used to store web font faces that were added to the
* canvas text renderer.
*/
fontFamilies = {};
fontFamilyArray = [this.fontFamilies];
type = 'canvas';
constructor(stage) {
super(stage);
if (typeof OffscreenCanvas !== 'undefined') {
this.canvas = new OffscreenCanvas(0, 0);
}
else {
this.canvas = document.createElement('canvas');
}
let context = this.canvas.getContext('2d', {
willReadFrequently: true,
});
if (!context) {
// A browser may appear to support OffscreenCanvas but not actually support the Canvas '2d' context
// Here we try getting the context again after falling back to an HTMLCanvasElement.
// See: https://github.com/lightning-js/renderer/issues/26#issuecomment-1750438486
this.canvas = document.createElement('canvas');
context = this.canvas.getContext('2d', {
willReadFrequently: true,
});
}
assertTruthy(context);
this.context = context;
// Install the default 'san-serif' font face
this.addFontFace(new WebTrFontFace({
fontFamily: 'sans-serif',
descriptors: {},
fontUrl: '',
}));
}
//#region Overrides
getPropertySetters() {
return {
fontFamily: (state, value) => {
state.props.fontFamily = value;
state.fontInfo = undefined;
this.invalidateLayoutCache(state);
},
fontWeight: (state, value) => {
state.props.fontWeight = value;
state.fontInfo = undefined;
this.invalidateLayoutCache(state);
},
fontStyle: (state, value) => {
state.props.fontStyle = value;
state.fontInfo = undefined;
this.invalidateLayoutCache(state);
},
fontStretch: (state, value) => {
state.props.fontStretch = value;
state.fontInfo = undefined;
this.invalidateLayoutCache(state);
},
fontSize: (state, value) => {
state.props.fontSize = value;
state.fontInfo = undefined;
this.invalidateLayoutCache(state);
},
text: (state, value) => {
state.props.text = value;
this.invalidateLayoutCache(state);
},
textAlign: (state, value) => {
state.props.textAlign = value;
this.invalidateLayoutCache(state);
},
color: (state, value) => {
state.props.color = value;
this.invalidateLayoutCache(state);
},
x: (state, value) => {
state.props.x = value;
},
y: (state, value) => {
state.props.y = value;
},
contain: (state, value) => {
state.props.contain = value;
this.invalidateLayoutCache(state);
},
width: (state, value) => {
state.props.width = value;
// Only invalidate layout cache if we're containing in the horizontal direction
if (state.props.contain !== 'none') {
this.invalidateLayoutCache(state);
}
},
height: (state, value) => {
state.props.height = value;
// Only invalidate layout cache if we're containing in the vertical direction
if (state.props.contain === 'both') {
this.invalidateLayoutCache(state);
}
},
offsetY: (state, value) => {
state.props.offsetY = value;
this.invalidateLayoutCache(state);
},
scrollY: (state, value) => {
state.props.scrollY = value;
},
letterSpacing: (state, value) => {
state.props.letterSpacing = value;
this.invalidateLayoutCache(state);
},
lineHeight: (state, value) => {
state.props.lineHeight = value;
this.invalidateLayoutCache(state);
},
maxLines: (state, value) => {
state.props.maxLines = value;
this.invalidateLayoutCache(state);
},
textBaseline: (state, value) => {
state.props.textBaseline = value;
this.invalidateLayoutCache(state);
},
verticalAlign: (state, value) => {
state.props.verticalAlign = value;
this.invalidateLayoutCache(state);
},
overflowSuffix: (state, value) => {
state.props.overflowSuffix = value;
this.invalidateLayoutCache(state);
},
};
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
canRenderFont(props) {
// The canvas renderer can render any font because it automatically
// falls back to system fonts. The CanvasTextRenderer should be
// checked last if other renderers are preferred.
return true;
}
isFontFaceSupported(fontFace) {
return fontFace instanceof WebTrFontFace;
}
addFontFace(fontFace) {
// Make sure the font face is an Canvas font face (it should have already passed
// the `isFontFaceSupported` check)
assertTruthy(fontFace instanceof WebTrFontFace);
const fontFamily = fontFace.fontFamily;
// Add the font face to the document
// Except for the 'sans-serif' font family, which the Renderer provides
// as a special default fallback.
if (fontFamily !== 'sans-serif') {
// @ts-expect-error `add()` method should be available from a FontFaceSet
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
globalFontSet.add(fontFace.fontFace);
}
let faceSet = this.fontFamilies[fontFamily];
if (!faceSet) {
faceSet = new Set();
this.fontFamilies[fontFamily] = faceSet;
}
faceSet.add(fontFace);
}
createState(props, node) {
return {
node,
props,
status: 'initialState',
updateScheduled: false,
emitter: new EventEmitter(),
textureNode: undefined,
lightning2TextRenderer: new LightningTextTextureRenderer(this.canvas, this.context),
renderInfo: undefined,
forceFullLayoutCalc: false,
textW: 0,
textH: 0,
fontInfo: undefined,
isRenderable: false,
debugData: {
updateCount: 0,
layoutCount: 0,
drawCount: 0,
lastLayoutNumCharacters: 0,
layoutSum: 0,
drawSum: 0,
bufferSize: 0,
},
};
}
updateState(state) {
// On the first update call we need to set the status to loading
if (state.status === 'initialState') {
this.setStatus(state, 'loading');
// check if we're on screen
// if (this.isValidOnScreen(state) === true) {
// this.setStatus(state, 'loading');
// }
}
if (state.status === 'loaded') {
// If we're loaded, we don't need to do anything
return;
}
// If fontInfo is invalid, we need to establish it
if (!state.fontInfo) {
return this.loadFont(state);
}
// If we're waiting for a font face to load, don't render anything
if (!state.fontInfo.loaded) {
return;
}
if (!state.renderInfo) {
state.renderInfo = this.calculateRenderInfo(state);
state.textH = state.renderInfo.lineHeight * state.renderInfo.lines.length;
state.textW = state.renderInfo.width;
this.renderSingleCanvasPage(state);
}
// handle scrollable text !!!
// if (state.isScrollable === true) {
// return this.renderScrollableCanvasPages(state);
// }
// handle single page text
}
renderSingleCanvasPage(state) {
assertTruthy(state.renderInfo);
const node = state.node;
const texture = this.stage.txManager.createTexture('ImageTexture', {
premultiplyAlpha: true,
src: function (lightning2TextRenderer, renderInfo) {
// load the canvas texture
assertTruthy(renderInfo);
lightning2TextRenderer.draw(renderInfo, {
lines: renderInfo.lines,
lineWidths: renderInfo.lineWidths,
});
if (this.canvas.width === 0 || this.canvas.height === 0) {
return null;
}
return this.context.getImageData(0, 0, this.canvas.width, this.canvas.height);
}.bind(this, state.lightning2TextRenderer, state.renderInfo),
});
if (state.textureNode) {
// Use the existing texture node
state.textureNode.texture = texture;
// Update the alpha
state.textureNode.alpha = getNormalizedAlphaComponent(state.props.color);
}
else {
// Create a new texture node
const textureNode = this.stage.createNode({
parent: node,
texture,
autosize: true,
// The alpha channel of the color is ignored when rasterizing the text
// texture so we need to pass it directly to the texture node.
alpha: getNormalizedAlphaComponent(state.props.color),
});
state.textureNode = textureNode;
}
this.setStatus(state, 'loaded');
}
loadFont = (state) => {
const cssString = getFontCssString(state.props);
const trFontFace = this.stage.fontManager.resolveFontFace(this.fontFamilyArray, state.props, 'canvas');
assertTruthy(trFontFace, `Could not resolve font face for ${cssString}`);
state.fontInfo = {
fontFace: trFontFace,
cssString: cssString,
// TODO: For efficiency we would use this here but it's not reliable on WPE -> document.fonts.check(cssString),
loaded: false,
};
// If font is not loaded, set up a handler to update the font info when the font loads
if (!state.fontInfo.loaded) {
globalFontSet
.load(cssString)
.then(this.onFontLoaded.bind(this, state, cssString))
.catch(this.onFontLoadError.bind(this, state, cssString));
return;
}
};
calculateRenderInfo(state) {
state.lightning2TextRenderer.settings = {
text: state.props.text,
textAlign: state.props.textAlign,
fontFamily: state.props.fontFamily,
trFontFace: state.fontInfo?.fontFace,
fontSize: state.props.fontSize,
fontStyle: [
state.props.fontStretch,
state.props.fontStyle,
state.props.fontWeight,
].join(' '),
textColor: getNormalizedRgbaComponents(state.props.color),
offsetY: state.props.offsetY,
wordWrap: state.props.contain !== 'none',
wordWrapWidth: state.props.contain === 'none' ? undefined : state.props.width,
letterSpacing: state.props.letterSpacing,
lineHeight: state.props.lineHeight ?? null,
maxLines: state.props.maxLines,
maxHeight: state.props.contain === 'both'
? state.props.height - state.props.offsetY
: null,
textBaseline: state.props.textBaseline,
verticalAlign: state.props.verticalAlign,
overflowSuffix: state.props.overflowSuffix,
w: state.props.contain !== 'none' ? state.props.width : undefined,
};
state.renderInfo = state.lightning2TextRenderer.calculateRenderInfo();
return state.renderInfo;
}
renderQuads() {
// Do nothing. The renderer will render the child node(s) that were created
// in the state update.
return;
}
destroyState(state) {
if (state.status === 'destroyed') {
return;
}
super.destroyState(state);
if (state.textureNode) {
state.textureNode.destroy();
delete state.textureNode;
}
delete state.renderInfo;
}
//#endregion Overrides
/**
* Invalidate the layout cache stored in the state. This will cause the text
* to be re-rendered on the next update.
*
* @remarks
* This also invalidates the visible window cache.
*
* @param state
*/
invalidateLayoutCache(state) {
state.renderInfo = undefined;
this.setStatus(state, 'loading');
this.scheduleUpdateState(state);
}
onFontLoaded(state, cssString) {
if (cssString !== state.fontInfo?.cssString || !state.fontInfo) {
return;
}
state.fontInfo.loaded = true;
this.scheduleUpdateState(state);
}
onFontLoadError(state, cssString, error) {
if (cssString !== state.fontInfo?.cssString || !state.fontInfo) {
return;
}
// Font didn't actually load, but we'll log the error and mark it as loaded
// because the browser can still render with a fallback font.
state.fontInfo.loaded = true;
console.error(`CanvasTextRenderer: Error loading font '${state.fontInfo.cssString}'`, error);
this.scheduleUpdateState(state);
}
}
//# sourceMappingURL=CanvasTextRenderer.js.map