UNPKG

@lightningtv/renderer

Version:
397 lines 15.8 kB
/* * 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