@lightningtv/renderer
Version:
Lightning 3 Renderer
816 lines (720 loc) • 23.1 kB
text/typescript
/*
* 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, type RGBA } from '../../lib/utils.js';
import { calcDefaultLineHeight } from '../TextRenderingUtils.js';
import {
getWebFontMetrics,
isZeroWidthSpace,
} from '../TextTextureRendererUtils.js';
import type { NormalizedFontMetrics } from '../font-face-types/TrFontFace.js';
import type { WebTrFontFace } from '../font-face-types/WebTrFontFace.js';
const MAX_TEXTURE_DIMENSION = 2048;
/**
* Text Overflow Values
*/
export type TextOverflow =
| 'ellipsis'
| 'clip'
| (string & Record<never, never>);
/***
* Text Horizontal Align Values
*/
export type TextAlign = 'left' | 'center' | 'right';
/***
* Text Baseline Values
*/
export type TextBaseline =
| 'alphabetic'
| 'top'
| 'hanging'
| 'middle'
| 'ideographic'
| 'bottom';
/***
* Text Vertical Align Values
*/
export type TextVerticalAlign = 'top' | 'middle' | 'bottom';
/**
* Text Texture Settings
*/
export interface Settings {
w: number;
h: number;
text: string;
fontStyle: string;
fontSize: number;
fontBaselineRatio: number;
fontFamily: string | null;
trFontFace: WebTrFontFace | null;
wordWrap: boolean;
wordWrapWidth: number;
wordBreak: boolean;
textOverflow: TextOverflow | null;
lineHeight: number | null;
textBaseline: TextBaseline;
textAlign: TextAlign;
verticalAlign: TextVerticalAlign;
offsetY: number | null;
maxLines: number;
maxHeight: number | null;
overflowSuffix: string;
precision: number;
textColor: RGBA;
paddingLeft: number;
paddingRight: number;
shadow: boolean;
shadowColor: RGBA;
shadowOffsetX: number;
shadowOffsetY: number;
shadowBlur: number;
highlight: boolean;
highlightHeight: number;
highlightColor: RGBA;
highlightOffset: number;
highlightPaddingLeft: number;
highlightPaddingRight: number;
letterSpacing: number;
textIndent: number;
cutSx: number;
cutSy: number;
cutEx: number;
cutEy: number;
advancedRenderer: boolean;
// Normally stage options
textRenderIssueMargin: number;
}
export interface RenderInfo {
w: number;
h: number;
lines: string[];
precision: number;
remainingText: string;
moreTextLines: boolean;
width: number;
innerWidth: number;
height: number;
fontSize: number;
cutSx: number;
cutSy: number;
cutEx: number;
cutEy: number;
lineHeight: number;
defLineHeight: number;
lineWidths: number[];
offsetY: number;
paddingLeft: number;
paddingRight: number;
letterSpacing: number;
textIndent: number;
metrics: NormalizedFontMetrics;
}
export interface LineType {
text: string;
x: number;
y: number;
w: number;
}
/**
* Calculate height for the canvas
*
* @param textBaseline
* @param fontSize
* @param lineHeight
* @param numLines
* @param offsetY
* @returns
*/
function calcHeight(
textBaseline: TextBaseline,
fontSize: number,
lineHeight: number,
numLines: number,
offsetY: number | null,
) {
const baselineOffset = textBaseline !== 'bottom' ? 0.5 * fontSize : 0;
return (
lineHeight * (numLines - 1) +
baselineOffset +
Math.max(lineHeight, fontSize) +
(offsetY || 0)
);
}
export class LightningTextTextureRenderer {
private _canvas: OffscreenCanvas | HTMLCanvasElement;
private _context:
| OffscreenCanvasRenderingContext2D
| CanvasRenderingContext2D;
private _settings: Settings;
constructor(
canvas: OffscreenCanvas | HTMLCanvasElement,
context: OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D,
) {
this._canvas = canvas;
this._context = context;
this._settings = this.mergeDefaults({});
}
set settings(v: Partial<Settings>) {
this._settings = this.mergeDefaults(v);
}
get settings(): 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: string[] = [];
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(): RenderInfo {
const renderInfo: Partial<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: { n: number[]; l: string[] };
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: string[] | null = 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: number[] = [];
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 as RenderInfo;
}
draw(
renderInfo: RenderInfo,
linesOverride?: { lines: string[]; lineWidths: number[] },
) {
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: LineType[] = [];
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 | [string, number, number, number] = 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: string, wordWrapWidth: number, suffix: string) {
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: string,
wordWrapWidth: number,
letterSpacing: number,
indent = 0,
) {
const spaceRegex = / |\u200B/g; // ZWSP and spaces
const lines = text.split(/\r?\n/g);
let allLines: string[] = [];
const realNewlines: number[] = [];
for (let i = 0; i < lines.length; i++) {
const resultLines: string[] = [];
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: string, 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: Partial<Settings>): 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,
};
}
}