@lightningjs/renderer
Version:
Lightning 3 Renderer
412 lines • 14.6 kB
JavaScript
/*
* If not stated otherwise in this file or this component's LICENSE file the
* following copyright and licenses apply:
*
* Copyright 2025 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.
*/
// ---------------------------------------------------------------------------
// Character codes — avoids string allocation in the hot path
// ---------------------------------------------------------------------------
const CC_LBRACKET = 91; // '['
const CC_SLASH = 47; // '/'
const CC_ZERO = 48; // '0'
const CC_LC_X = 120; // 'x'
const CC_UC_X = 88; // 'X'
const CC_EQUALS = 61; // '='
// ---------------------------------------------------------------------------
// Tag type identifiers
// ---------------------------------------------------------------------------
const TAG_BOLD = 1;
const TAG_ITALIC = 2;
const TAG_UNDERLINE = 3;
const TAG_STRIKETHROUGH = 4;
const TAG_COLOR = 5;
// ---------------------------------------------------------------------------
// Pool limits
// ---------------------------------------------------------------------------
const MAX_SPANS = 64;
const MAX_STACK = 8;
// ---------------------------------------------------------------------------
// Pool limits
// ---------------------------------------------------------------------------
const namedColors = Object.create(null);
namedColors['red'] = 0xff0000ff;
namedColors['green'] = 0x00ff00ff;
namedColors['blue'] = 0x0000ffff;
namedColors['white'] = 0xffffffff;
namedColors['black'] = 0x000000ff;
namedColors['yellow'] = 0xffff00ff;
namedColors['cyan'] = 0x00ffffff;
namedColors['magenta'] = 0xff00ffff;
namedColors['orange'] = 0xff8000ff;
namedColors['purple'] = 0x800080ff;
namedColors['pink'] = 0xff69b4ff;
namedColors['brown'] = 0xa52a2aff;
namedColors['gray'] = 0x808080ff;
namedColors['grey'] = 0x808080ff;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/**
* A single styled run within stripped text.
*
* Instances live in the pre-allocated pool inside ParseResult and are reused
* across calls. Only indices [0, ParseResult.spanCount) are valid after a
* parse. Do not retain references across subsequent parseRichText calls on the
* same ParseResult instance.
*/
export class RichSpan {
start = 0; // inclusive index into stripped text
end = 0; // exclusive index into stripped text
bold = false;
italic = false;
underline = false;
strikethrough = false;
color = 0; // 0 = inherit node color; otherwise 0xRRGGBBAA
}
/**
* Output container for parseRichText.
*
* Create one instance per rendering module and reuse it. The internal span
* pool is pre-allocated in the constructor — zero heap allocation per parse.
*/
export class ParseResult {
stripped = '';
spanCount = 0;
spans;
constructor() {
this.spans = new Array(MAX_SPANS);
for (let i = 0; i < MAX_SPANS; i++) {
this.spans[i] = new RichSpan();
}
}
reset() {
this.stripped = '';
this.spanCount = 0;
}
}
// ---------------------------------------------------------------------------
// Internal: style stack (module-level singleton — JS is single-threaded)
// ---------------------------------------------------------------------------
class StyleFrame {
bold = false;
italic = false;
underline = false;
strikethrough = false;
color = 0;
tagType = 0; // TAG_* constant that opened this frame; 0 = base
copyFrom(src) {
this.bold = src.bold;
this.italic = src.italic;
this.underline = src.underline;
this.strikethrough = src.strikethrough;
this.color = src.color;
this.tagType = src.tagType;
}
}
const _stack = new Array(MAX_STACK);
for (let _i = 0; _i < MAX_STACK; _i++) {
_stack[_i] = new StyleFrame();
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
/**
* Returns the numeric value of a hex digit char code, or -1 when invalid.
*/
const hexDigitVal = (code) => {
if (code >= 48 && code <= 57)
return code - 48; // '0'-'9'
if (code >= 97 && code <= 102)
return code - 87; // 'a'-'f'
if (code >= 65 && code <= 70)
return code - 55; // 'A'-'F'
return -1;
};
/**
* Parses a color value from text[start, tagEnd).
*
* Accepted format: 0xRRGGBBAA (8 hex digits after the 0x prefix), matching
* the renderer's internal color representation exactly.
* Returns the color as a 0xRRGGBBAA unsigned 32-bit integer, or -1 when the
* value is not recognised.
*/
const parseColorValue = (text, start, tagEnd) => {
if (start >= tagEnd)
return -1;
const firstChar = text.charCodeAt(start);
// ---- 0xRRGGBBAA — renderer internal format (8 hex digits) ----
if (firstChar === CC_ZERO) {
const secondChar = text.charCodeAt(start + 1);
if (secondChar === CC_LC_X || secondChar === CC_UC_X) {
const hexLen = tagEnd - start - 2; // digits after '0x'
if (hexLen === 8) {
let v = 0;
for (let k = 2; k < 10; k++) {
const d = hexDigitVal(text.charCodeAt(start + k));
if (d === -1)
return -1;
v = v * 16 + d;
}
return v >>> 0;
}
}
}
return -1;
};
/**
* Identifies the tag at text[pos, tagEnd) where pos is the first char of the
* tag name (after '[' and any leading '/').
*
* Returns a TAG_* constant, or 0 when the tag is not recognised.
*/
const identifyTag = (text, pos, tagEnd) => {
const firstChar = text.charCodeAt(pos);
const tagLen = tagEnd - pos;
// Single-char tags: b i u s
if (tagLen === 1) {
if (firstChar === 98)
return TAG_BOLD; // 'b'
if (firstChar === 105)
return TAG_ITALIC; // 'i'
if (firstChar === 117)
return TAG_UNDERLINE; // 'u'
if (firstChar === 115)
return TAG_STRIKETHROUGH; // 's'
return 0;
}
// 'color' (5 chars, closing tag) or 'color=...' (6+ chars, opening tag)
if (firstChar === 99 && // 'c'
text.charCodeAt(pos + 1) === 111 && // 'o'
text.charCodeAt(pos + 2) === 108 && // 'l'
text.charCodeAt(pos + 3) === 111 && // 'o'
text.charCodeAt(pos + 4) === 114 && // 'r'
(tagLen === 5 || text.charCodeAt(pos + 5) === CC_EQUALS) // bare or 'color='
) {
return TAG_COLOR;
}
return 0;
};
/**
* Applies one tag to the style stack and returns the new stack depth.
*
* Opening: pushes a new frame copying the current style then modifying it.
* Closing: searches backwards for the nearest matching opening frame and pops
* back to the frame before it, implicitly closing any intervening
* tags (handles mis-nested markup gracefully).
*/
const applyTag = (tagType, isClosing, stackDepth, colorValue) => {
if (isClosing === true) {
for (let k = stackDepth; k > 0; k--) {
if (_stack[k].tagType === tagType) {
return k - 1;
}
}
return stackDepth; // no matching open tag — ignore
}
if (stackDepth >= MAX_STACK - 1)
return stackDepth; // stack full — ignore
const next = stackDepth + 1;
_stack[next].copyFrom(_stack[stackDepth]);
_stack[next].tagType = tagType;
if (tagType === TAG_BOLD) {
_stack[next].bold = true;
}
else if (tagType === TAG_ITALIC) {
_stack[next].italic = true;
}
else if (tagType === TAG_UNDERLINE) {
_stack[next].underline = true;
}
else if (tagType === TAG_STRIKETHROUGH) {
_stack[next].strikethrough = true;
}
else if (tagType === TAG_COLOR) {
_stack[next].color = colorValue;
}
return next;
};
/**
* Writes a completed span into the pool when the range [start, end) is
* non-empty and the pool has capacity. Returns the updated spanCount.
*/
const flushSpan = (spans, spanCount, start, end, style) => {
if (end <= start)
return spanCount;
if (spanCount >= MAX_SPANS)
return spanCount;
const span = spans[spanCount];
span.start = start;
span.end = end;
span.bold = style.bold;
span.italic = style.italic;
span.underline = style.underline;
span.strikethrough = style.strikethrough;
span.color = style.color;
return spanCount + 1;
};
/**
* Returns true when two frames produce identical visible output (tagType is
* internal metadata and is not compared).
*/
const stylesEqual = (a, b) => a.bold === b.bold &&
a.italic === b.italic &&
a.underline === b.underline &&
a.strikethrough === b.strikethrough &&
a.color === b.color;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Parses BB-code formatted text into stripped plain text and an array of
* styled spans.
*
* Supported tags (all lowercase, nestable):
* [b]…[/b] bold
* [i]…[/i] italic
* [u]…[/u] underline
* [s]…[/s] strikethrough
* [color=0xRRGGBBAA]…[/color] inline color (VALUE must be a 0xRRGGBBAA
* hex literal matching the renderer's internal
* color format, e.g. [color=0xff0000ff])
*
* Unrecognised or malformed tags are emitted as literal text.
* Mis-nested closing tags implicitly close any intervening open tags.
*
* The result is written into `out`. Span data is valid for indices
* [0, out.spanCount). Do not retain span references across subsequent calls
* on the same `out` instance.
*
* Performance: single-pass, O(n) in text length. Zero heap allocation in the
* hot path — all spans and stack frames are reused from pre-allocated pools.
*/
export const parseRichText = (text, out) => {
out.reset();
const textLen = text.length;
if (textLen === 0)
return;
// Reset base stack frame to defaults
const base = _stack[0];
base.bold = false;
base.italic = false;
base.underline = false;
base.strikethrough = false;
base.color = 0;
base.tagType = 0;
let stackDepth = 0;
let spanStart = 0; // start of current span in stripped text
let spanCount = 0;
let stripped = '';
let runStart = 0; // start of current literal run in original text
let i = 0;
while (i < textLen) {
if (text.charCodeAt(i) !== CC_LBRACKET) {
i++;
continue;
}
// Possible tag — find closing ']'
const tagEnd = text.indexOf(']', i + 1);
if (tagEnd === -1) {
i++;
continue; // no ']' found — treat '[' as literal
}
let pos = i + 1;
let isClosing = false;
if (text.charCodeAt(pos) === CC_SLASH) {
isClosing = true;
pos++;
}
if (pos >= tagEnd) {
i++;
continue; // empty tag '[]' or '[/]' — literal
}
const tagType = identifyTag(text, pos, tagEnd);
if (tagType === 0) {
i++;
continue; // unrecognised tag — treat '[' as literal
}
// Resolve color value for opening [color=...] before touching the stack
let colorValue = 0;
if (tagType === TAG_COLOR && isClosing === false) {
colorValue = parseColorValue(text, pos + 6, tagEnd); // skip 'color='
if (colorValue === -1) {
i++;
continue; // invalid color value — treat '[' as literal
}
}
// Flush the literal run [runStart, i) into stripped
stripped += text.substring(runStart, i);
runStart = tagEnd + 1;
// Apply the tag to the stack
const prevDepth = stackDepth;
stackDepth = applyTag(tagType, isClosing, stackDepth, colorValue);
// When the visible style changes, close the current span
if (stylesEqual(_stack[prevDepth], _stack[stackDepth]) === false) {
spanCount = flushSpan(out.spans, spanCount, spanStart, stripped.length, _stack[prevDepth]);
spanStart = stripped.length;
}
i = tagEnd + 1;
}
// Flush the final literal run
stripped += text.substring(runStart, textLen);
// Close any open span
spanCount = flushSpan(out.spans, spanCount, spanStart, stripped.length, _stack[stackDepth]);
out.stripped = stripped;
out.spanCount = spanCount;
};
/**
* Returns the plain text with all recognised BB-code tags removed.
*
* Faster than parseRichText when span metadata is not needed (e.g. layout
* measurement). Unrecognised tags are preserved as literal text.
*
* Fast-paths text that contains no '[' characters with a single indexOf check.
*/
export const stripRichText = (text) => {
if (text.indexOf('[') === -1)
return text;
let stripped = '';
let runStart = 0;
let i = 0;
const textLen = text.length;
while (i < textLen) {
if (text.charCodeAt(i) !== CC_LBRACKET) {
i++;
continue;
}
const tagEnd = text.indexOf(']', i + 1);
if (tagEnd === -1) {
i++;
continue;
}
let pos = i + 1;
if (text.charCodeAt(pos) === CC_SLASH)
pos++;
if (pos >= tagEnd) {
i++;
continue;
}
if (identifyTag(text, pos, tagEnd) === 0) {
i++;
continue; // unrecognised — keep literal
}
stripped += text.substring(runStart, i);
runStart = tagEnd + 1;
i = tagEnd + 1;
}
stripped += text.substring(runStart, textLen);
return stripped;
};
//# sourceMappingURL=RichTextParser.js.map