@lightningjs/renderer
Version:
Lightning 3 Renderer
445 lines (397 loc) • 15 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.
*/
import { assertTruthy } from '../../utils.js';
import type { Stage } from '../Stage.js';
import type {
CanvasRenderInfo,
FontHandler,
TextLineStruct,
TextRenderInfo,
} from './TextRenderer.js';
import * as CanvasFontHandler from './CanvasFontHandler.js';
import type { CoreTextNodeProps } from '../CoreTextNode.js';
import { getLayoutCacheKey, hasZeroWidthSpace } from './Utils.js';
import { mapTextLayout } from './TextLayoutEngine.js';
import { parseRichText, ParseResult } from './RichTextParser.js';
import { normalizeCanvasColor } from '../lib/colorCache.js';
const type = 'canvas' as const;
const font: FontHandler = CanvasFontHandler;
let stage: Stage | null = null;
let canvas: HTMLCanvasElement | OffscreenCanvas | null = null;
let context:
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null = null;
// Whether the drawing canvas is an OffscreenCanvas (supports transferToImageBitmap)
let isOffscreen = false;
// Separate canvas and context for text measurements
let measureCanvas: HTMLCanvasElement | OffscreenCanvas | null = null;
type MeasureContext =
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D
| null;
let measureContext: MeasureContext = null;
// Cache for text layout calculations
const renderInfoCache = new Map<string, CanvasRenderInfo>();
// Pre-allocated rich text parse result — reused across renderText calls.
// Safe because renderText is synchronous and JS is single-threaded.
const _richTextResult = new ParseResult();
// Initialize the Text Renderer
const init = (_stage: Stage): void => {
stage = _stage;
const dpr = stage.options.devicePhysicalPixelRatio;
// Drawing canvas and context
canvas = stage.platform.createOffscreenCanvas();
if (canvas !== null) {
isOffscreen = true;
measureCanvas = stage.platform.createOffscreenCanvas();
} else {
isOffscreen = false;
canvas = stage.platform.createCanvas();
measureCanvas = stage.platform.createCanvas();
}
context = canvas.getContext('2d') as
| CanvasRenderingContext2D
| OffscreenCanvasRenderingContext2D;
assertTruthy(context, '.getContext(2d) failed');
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.textRendering = 'optimizeSpeed';
assertTruthy(measureCanvas, 'measureCanvas is not initialized');
measureContext = measureCanvas.getContext('2d') as MeasureContext;
assertTruthy(measureContext, '.getContext(2d) failed');
measureContext.setTransform(dpr, 0, 0, dpr, 0, 0);
measureContext.textRendering = 'optimizeSpeed';
// Set up a minimal size for the measuring canvas since we only use it for measurements
measureCanvas.width = 1;
measureCanvas.height = 1;
CanvasFontHandler.init(context, measureContext);
};
/**
* Canvas text renderer
*
* @param stage - Stage instance for font resolution
* @param props - Text rendering properties
* @returns Object containing ImageData and dimensions
*/
const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
assertTruthy(canvas, 'Canvas is not initialized');
assertTruthy(context, 'Canvas context is not available');
assertTruthy(measureContext, 'Canvas measureContext is not available');
const cacheKey = getLayoutCacheKey(props);
let layout = renderInfoCache.get(cacheKey);
if (layout !== undefined) {
return layout;
}
// Extract already normalized properties
const {
text,
fontFamily,
fontStyle,
fontSize,
textAlign,
maxLines,
lineHeight,
overflowSuffix,
maxWidth,
maxHeight,
wordBreak,
richText,
color,
} = props;
// When richText is enabled, parse BB-code tags from text and use the
// stripped plain text for layout. The parse result is stored in the
// module-level singleton (safe — renderText is synchronous).
let layoutText = text;
if (richText === true) {
parseRichText(text, _richTextResult);
layoutText = _richTextResult.stripped;
}
// Rename to baseFont to avoid shadowing the module-level FontHandler
const baseFont = `${fontStyle} ${fontSize}px Unknown, ${fontFamily}`;
// Get font metrics and calculate line height
measureContext.font = baseFont;
measureContext.textBaseline = 'hanging';
const metrics = CanvasFontHandler.getFontMetrics(fontFamily, fontSize);
const letterSpacing = props.letterSpacing || 0;
const [
lines,
remainingLines,
hasRemainingText,
_bareLineHeight,
_lineHeightPx,
effectiveWidth,
effectiveHeight,
] = mapTextLayout(
CanvasFontHandler.measureText,
metrics,
layoutText,
textAlign,
fontFamily,
lineHeight,
overflowSuffix,
wordBreak,
letterSpacing,
maxLines,
maxWidth,
maxHeight,
);
const lineAmount = lines.length;
const canvasW = Math.ceil(effectiveWidth);
const canvasH = Math.ceil(effectiveHeight);
// For OffscreenCanvas we reuse the shared canvas and snapshot via
// transferToImageBitmap at the end. For HTMLCanvasElement we allocate a
// fresh canvas per text node so it can be passed directly as the texture
// source — no pixel readback or intermediate copy needed.
let drawCanvas: HTMLCanvasElement | OffscreenCanvas;
if (isOffscreen) {
drawCanvas = canvas;
} else {
assertTruthy(stage, 'Stage is not available');
drawCanvas = stage.platform.createCanvas();
context = drawCanvas.getContext('2d') as CanvasRenderingContext2D;
}
drawCanvas.width = canvasW;
drawCanvas.height = canvasH;
context.fillStyle = 'white';
context.font = baseFont;
context.textBaseline = 'hanging';
// Performance optimization for large fonts
if (fontSize >= 128) {
context.globalAlpha = 0.01;
context.fillRect(0, 0, 0.01, 0.01);
context.globalAlpha = 1.0;
}
if (richText === true) {
// -------------------------------------------------------------------------
// Rich text draw path — segment-by-segment with per-span font and color.
//
// strippedPos tracks the absolute character position in stripped text as
// we walk through layout lines. curSpanIdx advances monotonically with
// strippedPos (spans are sorted by start, no backward scan is needed).
//
// Note: layout (mapTextLayout) used the base font metrics, so bold text
// may render slightly wider than its measured width — acceptable for MVP.
// -------------------------------------------------------------------------
const spanCount = _richTextResult.spanCount;
const spans = _richTextResult.spans;
// CSS color string for uncolored spans — inherits the node's text color.
// normalizeCanvasColor caches the string, so no allocation in hot path.
const nodeColor = normalizeCanvasColor(color, true);
let strippedPos = 0;
let curSpanIdx = 0;
let activeFont = baseFont;
let activeFillStyle = nodeColor;
// Prime the context with the node color; individual segments may override.
context.fillStyle = activeFillStyle;
// Pre-computed decoration geometry — all integers, zero allocation in the
// draw loop. Positions are offsets from currentY (top of the em box with
// textBaseline:'hanging'). metrics.ascender is the distance from the
// hanging baseline down to the alphabetic baseline in pixels.
const ascenderPx = metrics.ascender;
const decoThickness = Math.max(1, Math.round(fontSize / 20));
const decoUnderlineBase =
Math.ceil(ascenderPx) + Math.max(1, Math.round(fontSize * 0.08));
const decoStrikeBase = Math.ceil(ascenderPx) - Math.round(ascenderPx * 0.4);
for (let i = 0; i < lineAmount; i++) {
const line = lines[i] as TextLineStruct;
const textLine = line[0];
const lineLen = textLine.length;
let currentX = Math.ceil(line[3]);
const currentY = Math.ceil(line[4]);
// Advance span pointer to cover strippedPos (first char of this line).
while (
curSpanIdx < spanCount - 1 &&
strippedPos >= spans[curSpanIdx]!.end
) {
curSpanIdx++;
}
let segStartJ = 0;
let segSpanIdx = curSpanIdx;
// Iterate one past the line end so the final segment is always flushed.
for (let j = 1; j <= lineLen; j++) {
// Determine the span index for character at (strippedPos + j).
// At end-of-line (j === lineLen) we force a flush without advancing.
let nextSpanIdx = segSpanIdx;
if (j < lineLen) {
while (
nextSpanIdx < spanCount - 1 &&
strippedPos + j >= spans[nextSpanIdx]!.end
) {
nextSpanIdx++;
}
curSpanIdx = nextSpanIdx;
}
if (j === lineLen || nextSpanIdx !== segSpanIdx) {
// Flush segment [segStartJ, j) with the style from spans[segSpanIdx].
const span = spans[segSpanIdx]!;
// Build the CSS font string for this segment.
// Bold/italic from the span override the node-level fontStyle.
const spanStyle = span.italic === true ? 'italic' : fontStyle;
const spanFont =
span.bold === true
? `${spanStyle} bold ${fontSize}px Unknown, ${fontFamily}`
: `${spanStyle} ${fontSize}px Unknown, ${fontFamily}`;
if (spanFont !== activeFont) {
context.font = spanFont;
activeFont = spanFont;
}
// Colored span uses span.color; uncolored span inherits node color.
const spanFillStyle =
span.color !== 0
? normalizeCanvasColor(span.color, true)
: nodeColor;
if (spanFillStyle !== activeFillStyle) {
context.fillStyle = spanFillStyle;
activeFillStyle = spanFillStyle;
}
const segStartX = currentX;
if (letterSpacing === 0) {
const segment = textLine.substring(segStartJ, j);
context.fillText(segment, currentX, currentY);
currentX += CanvasFontHandler.measureText(segment, fontFamily, 0);
} else {
for (let k = segStartJ; k < j; k++) {
const char = textLine.charAt(k);
if (hasZeroWidthSpace(char) === false) {
context.fillText(char, currentX, currentY);
}
currentX += CanvasFontHandler.measureText(
char,
fontFamily,
letterSpacing,
);
}
}
// Draw underline and/or strikethrough using the same fillStyle as
// the segment text (decorations inherit span color, matching CSS).
if (span.underline === true || span.strikethrough === true) {
const segWidth = currentX - segStartX;
if (segWidth > 0) {
if (span.underline === true) {
context.fillRect(
segStartX,
currentY + decoUnderlineBase,
segWidth,
decoThickness,
);
}
if (span.strikethrough === true) {
context.fillRect(
segStartX,
currentY + decoStrikeBase,
segWidth,
decoThickness,
);
}
}
}
segStartJ = j;
segSpanIdx = nextSpanIdx;
}
}
// Advance strippedPos past this line's characters plus the line-break
// character (space consumed by wrapText, or \n for explicit newlines)
// that separates layout lines. The final line has no trailing break.
strippedPos += lineLen + (i < lineAmount - 1 ? 1 : 0);
}
} else {
// -------------------------------------------------------------------------
// Plain text draw path — unchanged from original implementation.
// -------------------------------------------------------------------------
for (let i = 0; i < lineAmount; i++) {
const line = lines[i] as TextLineStruct;
const textLine = line[0];
let currentX = Math.ceil(line[3]);
const currentY = Math.ceil(line[4]);
if (letterSpacing === 0) {
context.fillText(textLine, currentX, currentY);
} else {
const textLineLength = textLine.length;
for (let j = 0; j < textLineLength; j++) {
const char = textLine.charAt(j);
if (hasZeroWidthSpace(char) === true) {
continue;
}
context.fillText(char, currentX, currentY);
currentX += CanvasFontHandler.measureText(
char,
fontFamily,
letterSpacing,
);
}
}
}
}
// Capture the rendered text as a texture source.
// OffscreenCanvas: transferToImageBitmap detaches the backing bitmap
// synchronously (zero-copy). The shared canvas gets a fresh empty bitmap.
// HTMLCanvasElement: drawCanvas was allocated specifically for this text
// node, so it can be passed directly — no copy needed.
let imageData: ImageBitmap | HTMLCanvasElement | null = null;
if (drawCanvas.width > 0 && drawCanvas.height > 0) {
if (isOffscreen) {
assertTruthy(
drawCanvas instanceof OffscreenCanvas,
'drawCanvas is not an OffscreenCanvas',
);
imageData = drawCanvas.transferToImageBitmap();
} else {
assertTruthy(
drawCanvas instanceof HTMLCanvasElement,
'drawCanvas is not an HTMLCanvasElement',
);
imageData = drawCanvas;
}
}
const renderInfo = {
type,
imageData,
width: effectiveWidth,
height: effectiveHeight,
remainingLines,
hasRemainingText,
} as CanvasRenderInfo;
renderInfoCache.set(cacheKey, renderInfo);
return renderInfo;
};
/**
* Clear layout cache for memory management
*/
const clearCache = (): void => {
renderInfoCache.clear();
};
/**
* Render quads for Canvas renderer (Canvas doesn't use quad-based rendering)
*/
const renderQuads = (): void => {
// Canvas renderer doesn't use quad-based rendering
// This method is for interface compatibility only
};
/**
* Canvas Text Renderer - implements TextRenderer interface
*/
const CanvasTextRenderer = {
type,
font,
renderText,
renderQuads,
init,
clearCache,
};
export default CanvasTextRenderer;