@lightningtv/renderer
Version:
Lightning 3 Renderer
551 lines • 22.4 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.
*/
/* 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