UNPKG

@lightningtv/renderer

Version:
551 lines 22.4 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. */ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import { assertTruthy } from '../../../utils.js'; import { getRgbaString } from '../../lib/utils.js'; import { calcDefaultLineHeight } from '../TextRenderingUtils.js'; import { getWebFontMetrics, isZeroWidthSpace, } from '../TextTextureRendererUtils.js'; const MAX_TEXTURE_DIMENSION = 2048; /** * Calculate height for the canvas * * @param textBaseline * @param fontSize * @param lineHeight * @param numLines * @param offsetY * @returns */ function calcHeight(textBaseline, fontSize, lineHeight, numLines, offsetY) { const baselineOffset = textBaseline !== 'bottom' ? 0.5 * fontSize : 0; return (lineHeight * (numLines - 1) + baselineOffset + Math.max(lineHeight, fontSize) + (offsetY || 0)); } export class LightningTextTextureRenderer { _canvas; _context; _settings; constructor(canvas, context) { this._canvas = canvas; this._context = context; this._settings = this.mergeDefaults({}); } set settings(v) { this._settings = this.mergeDefaults(v); } get settings() { return this._settings; } getPrecision() { return this._settings.precision; } setFontProperties() { this._context.font = this._getFontSetting(); this._context.textBaseline = this._settings.textBaseline; } _getFontSetting() { const ff = [this._settings.fontFamily]; const ffs = []; for (let i = 0, n = ff.length; i < n; i++) { if (ff[i] === 'serif' || ff[i] === 'sans-serif') { ffs.push(ff[i]); } else { ffs.push(`"${ff[i]}"`); } } return `${this._settings.fontStyle} ${this._settings.fontSize * this.getPrecision()}px ${ffs.join(',')}`; } _load() { if (true && document.fonts) { const fontSetting = this._getFontSetting(); try { if (!document.fonts.check(fontSetting, this._settings.text)) { // Use a promise that waits for loading. return document.fonts .load(fontSetting, this._settings.text) .catch((err) => { // Just load the fallback font. console.warn('[Lightning] Font load error', err, fontSetting); }) .then(() => { if (!document.fonts.check(fontSetting, this._settings.text)) { console.warn('[Lightning] Font not found', fontSetting); } }); } } catch (e) { console.warn("[Lightning] Can't check font loading for " + fontSetting); } } } calculateRenderInfo() { const renderInfo = {}; const precision = this.getPrecision(); const paddingLeft = this._settings.paddingLeft * precision; const paddingRight = this._settings.paddingRight * precision; const fontSize = this._settings.fontSize * precision; let offsetY = this._settings.offsetY === null ? null : this._settings.offsetY * precision; const w = this._settings.w * precision; const h = this._settings.h * precision; let wordWrapWidth = this._settings.wordWrapWidth * precision; const cutSx = this._settings.cutSx * precision; const cutEx = this._settings.cutEx * precision; const cutSy = this._settings.cutSy * precision; const cutEy = this._settings.cutEy * precision; const letterSpacing = (this._settings.letterSpacing || 0) * precision; const textIndent = this._settings.textIndent * precision; const trFontFace = this._settings.trFontFace; // Set font properties. this.setFontProperties(); assertTruthy(trFontFace); const metrics = getWebFontMetrics(this._context, trFontFace, fontSize); const defLineHeight = calcDefaultLineHeight(metrics, fontSize) * precision; const lineHeight = this._settings.lineHeight !== null ? this._settings.lineHeight * precision : defLineHeight; const maxHeight = this._settings.maxHeight; const containedMaxLines = maxHeight !== null && lineHeight > 0 ? Math.floor(maxHeight / lineHeight) : 0; const setMaxLines = this._settings.maxLines; const calcMaxLines = containedMaxLines > 0 && setMaxLines > 0 ? Math.min(containedMaxLines, setMaxLines) : Math.max(containedMaxLines, setMaxLines); // Total width. let width = w || 2048 / this.getPrecision(); // Inner width. let innerWidth = width - paddingLeft; if (innerWidth < 10) { width += 10 - innerWidth; innerWidth = 10; } if (!wordWrapWidth) { wordWrapWidth = innerWidth; } // Text overflow if (this._settings.textOverflow && !this._settings.wordWrap) { let suffix; switch (this._settings.textOverflow) { case 'clip': suffix = ''; break; case 'ellipsis': suffix = this._settings.overflowSuffix; break; default: suffix = this._settings.textOverflow; } this._settings.text = this.wrapWord(this._settings.text, wordWrapWidth - textIndent, suffix); } // word wrap // preserve original text let linesInfo; if (this._settings.wordWrap) { linesInfo = this.wrapText(this._settings.text, wordWrapWidth, letterSpacing, textIndent); } else { linesInfo = { l: this._settings.text.split(/(?:\r\n|\r|\n)/), n: [] }; const n = linesInfo.l.length; for (let i = 0; i < n - 1; i++) { linesInfo.n.push(i); } } let lines = linesInfo.l; if (calcMaxLines && lines.length > calcMaxLines) { const usedLines = lines.slice(0, calcMaxLines); let otherLines = null; if (this._settings.overflowSuffix) { // Wrap again with max lines suffix enabled. const w = this._settings.overflowSuffix ? this.measureText(this._settings.overflowSuffix) : 0; const al = this.wrapText(usedLines[usedLines.length - 1], wordWrapWidth - w, letterSpacing, textIndent); usedLines[usedLines.length - 1] = `${al.l[0]}${this._settings.overflowSuffix}`; otherLines = [al.l.length > 1 ? al.l[1] : '']; } else { otherLines = ['']; } // Re-assemble the remaining text. let i; const n = lines.length; let j = 0; const m = linesInfo.n.length; for (i = calcMaxLines; i < n; i++) { otherLines[j] += `${otherLines[j] ? ' ' : ''}${lines[i]}`; if (i + 1 < m && linesInfo.n[i + 1]) { j++; } } renderInfo.remainingText = otherLines.join('\n'); renderInfo.moreTextLines = true; lines = usedLines; } else { renderInfo.moreTextLines = false; renderInfo.remainingText = ''; } // calculate text width let maxLineWidth = 0; const lineWidths = []; for (let i = 0; i < lines.length; i++) { const lineWidth = this.measureText(lines[i], letterSpacing) + (i === 0 ? textIndent : 0); lineWidths.push(lineWidth); maxLineWidth = Math.max(maxLineWidth, lineWidth); } renderInfo.lineWidths = lineWidths; if (!w) { // Auto-set width to max text length. width = maxLineWidth + paddingLeft + paddingRight; innerWidth = maxLineWidth; } // If word wrap is enabled the width needs to be the width of the text. if (this._settings.wordWrap && w > maxLineWidth && this._settings.textAlign === 'left' && lines.length === 1) { width = maxLineWidth + paddingLeft + paddingRight; } let height; if (h) { height = h; } else { height = calcHeight(this._settings.textBaseline, fontSize, lineHeight, lines.length, offsetY); } if (offsetY === null) { offsetY = fontSize; } renderInfo.w = width; renderInfo.h = height; renderInfo.lines = lines; renderInfo.precision = precision; if (!width) { // To prevent canvas errors. width = 1; } if (!height) { // To prevent canvas errors. height = 1; } if (cutSx || cutEx) { width = Math.min(width, cutEx - cutSx); } if (cutSy || cutEy) { height = Math.min(height, cutEy - cutSy); } renderInfo.width = width; renderInfo.innerWidth = innerWidth; renderInfo.height = height; renderInfo.fontSize = fontSize; renderInfo.cutSx = cutSx; renderInfo.cutSy = cutSy; renderInfo.cutEx = cutEx; renderInfo.cutEy = cutEy; renderInfo.lineHeight = lineHeight; renderInfo.defLineHeight = defLineHeight; renderInfo.lineWidths = lineWidths; renderInfo.offsetY = offsetY; renderInfo.paddingLeft = paddingLeft; renderInfo.paddingRight = paddingRight; renderInfo.letterSpacing = letterSpacing; renderInfo.textIndent = textIndent; renderInfo.metrics = metrics; return renderInfo; } draw(renderInfo, linesOverride) { const precision = this.getPrecision(); // Allow lines to be overriden for partial rendering. const lines = linesOverride?.lines || renderInfo.lines; const lineWidths = linesOverride?.lineWidths || renderInfo.lineWidths; const height = linesOverride ? calcHeight(this._settings.textBaseline, renderInfo.fontSize, renderInfo.lineHeight, linesOverride.lines.length, this._settings.offsetY === null ? null : this._settings.offsetY * precision) : renderInfo.height; // Add extra margin to prevent issue with clipped text when scaling. this._canvas.width = Math.min(Math.ceil(renderInfo.width + this._settings.textRenderIssueMargin), MAX_TEXTURE_DIMENSION); this._canvas.height = Math.min(Math.ceil(height), MAX_TEXTURE_DIMENSION); // Canvas context has been reset. this.setFontProperties(); if (renderInfo.fontSize >= 128) { // WpeWebKit bug: must force compositing because cairo-traps-compositor will not work with text first. this._context.globalAlpha = 0.01; this._context.fillRect(0, 0, 0.01, 0.01); this._context.globalAlpha = 1.0; } if (renderInfo.cutSx || renderInfo.cutSy) { this._context.translate(-renderInfo.cutSx, -renderInfo.cutSy); } let linePositionX; let linePositionY; const drawLines = []; const { metrics } = renderInfo; /** * Ascender (in pixels) */ const ascenderPx = metrics ? metrics.ascender * renderInfo.fontSize : renderInfo.fontSize; /** * Bare line height is the distance between the ascender and descender of the font. * without the line gap metric. */ const bareLineHeightPx = (metrics.ascender - metrics.descender) * renderInfo.fontSize; // Draw lines line by line. for (let i = 0, n = lines.length; i < n; i++) { linePositionX = i === 0 ? renderInfo.textIndent : 0; // By default, text is aligned to top linePositionY = i * renderInfo.lineHeight + ascenderPx; if (this._settings.verticalAlign == 'middle') { linePositionY += (renderInfo.lineHeight - bareLineHeightPx) / 2; } else if (this._settings.verticalAlign == 'bottom') { linePositionY += renderInfo.lineHeight - bareLineHeightPx; } if (this._settings.textAlign === 'right') { linePositionX += renderInfo.innerWidth - lineWidths[i]; } else if (this._settings.textAlign === 'center') { linePositionX += (renderInfo.innerWidth - lineWidths[i]) / 2; } linePositionX += renderInfo.paddingLeft; drawLines.push({ text: lines[i], x: linePositionX, y: linePositionY, w: lineWidths[i], }); } // Highlight. if (this._settings.highlight) { const color = this._settings.highlightColor; const hlHeight = this._settings.highlightHeight * precision || renderInfo.fontSize * 1.5; const offset = this._settings.highlightOffset * precision; const hlPaddingLeft = this._settings.highlightPaddingLeft !== null ? this._settings.highlightPaddingLeft * precision : renderInfo.paddingLeft; const hlPaddingRight = this._settings.highlightPaddingRight !== null ? this._settings.highlightPaddingRight * precision : renderInfo.paddingRight; this._context.fillStyle = getRgbaString(color); for (let i = 0; i < drawLines.length; i++) { const drawLine = drawLines[i]; this._context.fillRect(drawLine.x - hlPaddingLeft, drawLine.y - renderInfo.offsetY + offset, drawLine.w + hlPaddingRight + hlPaddingLeft, hlHeight); } } // Text shadow. let prevShadowSettings = null; if (this._settings.shadow) { prevShadowSettings = [ this._context.shadowColor, this._context.shadowOffsetX, this._context.shadowOffsetY, this._context.shadowBlur, ]; this._context.shadowColor = getRgbaString(this._settings.shadowColor); this._context.shadowOffsetX = this._settings.shadowOffsetX * precision; this._context.shadowOffsetY = this._settings.shadowOffsetY * precision; this._context.shadowBlur = this._settings.shadowBlur * precision; } this._context.fillStyle = getRgbaString(this._settings.textColor); for (let i = 0, n = drawLines.length; i < n; i++) { const drawLine = drawLines[i]; if (renderInfo.letterSpacing === 0) { this._context.fillText(drawLine.text, drawLine.x, drawLine.y); } else { const textSplit = drawLine.text.split(''); let x = drawLine.x; for (let i = 0, j = textSplit.length; i < j; i++) { this._context.fillText(textSplit[i], x, drawLine.y); x += this.measureText(textSplit[i], renderInfo.letterSpacing); } } } if (prevShadowSettings) { this._context.shadowColor = prevShadowSettings[0]; this._context.shadowOffsetX = prevShadowSettings[1]; this._context.shadowOffsetY = prevShadowSettings[2]; this._context.shadowBlur = prevShadowSettings[3]; } if (renderInfo.cutSx || renderInfo.cutSy) { this._context.translate(renderInfo.cutSx, renderInfo.cutSy); } } wrapWord(word, wordWrapWidth, suffix) { const suffixWidth = this._context.measureText(suffix).width; const wordLen = word.length; const wordWidth = this._context.measureText(word).width; /* If word fits wrapWidth, do nothing */ if (wordWidth <= wordWrapWidth) { return word; } /* Make initial guess for text cuttoff */ let cutoffIndex = Math.floor((wordWrapWidth * wordLen) / wordWidth); let truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth; /* In case guess was overestimated, shrink it letter by letter. */ if (truncWordWidth > wordWrapWidth) { while (cutoffIndex > 0) { truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth; if (truncWordWidth > wordWrapWidth) { cutoffIndex -= 1; } else { break; } } /* In case guess was underestimated, extend it letter by letter. */ } else { while (cutoffIndex < wordLen) { truncWordWidth = this._context.measureText(word.substring(0, cutoffIndex)).width + suffixWidth; if (truncWordWidth < wordWrapWidth) { cutoffIndex += 1; } else { // Finally, when bound is crossed, retract last letter. cutoffIndex -= 1; break; } } } /* If wrapWidth is too short to even contain suffix alone, return empty string */ return (word.substring(0, cutoffIndex) + (wordWrapWidth >= suffixWidth ? suffix : '')); } /** * Applies newlines to a string to have it optimally fit into the horizontal * bounds set by the Text object's wordWrapWidth property. */ wrapText(text, wordWrapWidth, letterSpacing, indent = 0) { const spaceRegex = / |\u200B/g; // ZWSP and spaces const lines = text.split(/\r?\n/g); let allLines = []; const realNewlines = []; for (let i = 0; i < lines.length; i++) { const resultLines = []; let result = ''; let spaceLeft = wordWrapWidth - indent; // Split the line into words, considering ZWSP const words = lines[i].split(spaceRegex); const spaces = lines[i].match(spaceRegex) || []; for (let j = 0; j < words.length; j++) { const space = spaces[j - 1] || ''; const word = words[j]; const wordWidth = this.measureText(word, letterSpacing); const wordWidthWithSpace = isZeroWidthSpace(space) ? wordWidth : wordWidth + this.measureText(space, letterSpacing); if (j === 0 || wordWidthWithSpace > spaceLeft) { if (j > 0) { resultLines.push(result); result = ''; } result += word; spaceLeft = wordWrapWidth - wordWidth - (j === 0 ? indent : 0); } else { spaceLeft -= wordWidthWithSpace; result += space + word; } } resultLines.push(result); result = ''; allLines = allLines.concat(resultLines); if (i < lines.length - 1) { realNewlines.push(allLines.length); } } return { l: allLines, n: realNewlines }; } measureText(word, space = 0) { if (!space) { return this._context.measureText(word).width; } // Split word into characters, but skip ZWSP in the width calculation return word.split('').reduce((acc, char) => { // Check if the character is a zero-width space and skip it if (isZeroWidthSpace(char)) { return acc; } return acc + this._context.measureText(char).width + space; }, 0); } mergeDefaults(settings) { return { text: '', w: 0, h: 0, fontStyle: 'normal', fontSize: 40, fontFamily: null, trFontFace: null, wordWrap: true, wordWrapWidth: 0, wordBreak: false, textOverflow: '', lineHeight: null, textBaseline: 'alphabetic', textAlign: 'left', verticalAlign: 'top', offsetY: null, maxLines: 0, maxHeight: null, overflowSuffix: '...', textColor: [1.0, 1.0, 1.0, 1.0], paddingLeft: 0, paddingRight: 0, shadow: false, shadowColor: [0.0, 0.0, 0.0, 1.0], shadowOffsetX: 0, shadowOffsetY: 0, shadowBlur: 5, highlight: false, highlightHeight: 0, highlightColor: [0.0, 0.0, 0.0, 1.0], highlightOffset: 0, highlightPaddingLeft: 0, highlightPaddingRight: 0, letterSpacing: 0, textIndent: 0, cutSx: 0, cutEx: 0, cutSy: 0, cutEy: 0, advancedRenderer: false, fontBaselineRatio: 0, precision: 1, textRenderIssueMargin: 0, ...settings, }; } } //# sourceMappingURL=LightningTextTextureRenderer.js.map