UNPKG

@lightningjs/renderer

Version:
321 lines 15 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 { assertTruthy } from '../../utils.js'; import * as CanvasFontHandler from './CanvasFontHandler.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'; const font = CanvasFontHandler; let stage = null; let canvas = null; let context = null; // Whether the drawing canvas is an OffscreenCanvas (supports transferToImageBitmap) let isOffscreen = false; // Separate canvas and context for text measurements let measureCanvas = null; let measureContext = null; // Cache for text layout calculations const renderInfoCache = new Map(); // 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 = _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'); 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'); 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) => { 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; if (isOffscreen) { drawCanvas = canvas; } else { assertTruthy(stage, 'Stage is not available'); drawCanvas = stage.platform.createCanvas(); context = drawCanvas.getContext('2d'); } 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]; 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]; 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 = 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, }; renderInfoCache.set(cacheKey, renderInfo); return renderInfo; }; /** * Clear layout cache for memory management */ const clearCache = () => { renderInfoCache.clear(); }; /** * Render quads for Canvas renderer (Canvas doesn't use quad-based rendering) */ const renderQuads = () => { // 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; //# sourceMappingURL=CanvasTextRenderer.js.map