diff --git a/examples/tests/text-bbcode.ts b/examples/tests/text-bbcode.ts new file mode 100644 index 00000000..dbf47c15 --- /dev/null +++ b/examples/tests/text-bbcode.ts @@ -0,0 +1,206 @@ +/* + * 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. + */ + +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +export async function automation(settings: ExampleSettings) { + await test(settings); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + const fontSize = 28; + const fontFamily = 'Ubuntu'; + + // Main container + const view = renderer.createNode({ + x: 0, + y: 0, + w: 1250, + h: 600, + color: 0xf8f8f8ff, + parent: testRoot, + }); + + // Title + renderer.createTextNode({ + text: 'BBCode Formatting Test - SDF Renderer', + x: 20, + y: 30, + fontSize: 24, + fontFamily, + color: 0x000000ff, + parent: view, + }); + + // Subtitle + renderer.createTextNode({ + text: 'Demonstrating color and style BBCode tags', + x: 20, + y: 60, + fontSize: 16, + fontFamily, + color: 0x666666ff, + parent: view, + }); + + const examples = [ + { + title: 'Color Tags', + text: '[color=red]Red text[/color] [u]and[/u] [color=#00FF00][u]green[/u] text[/color]', + description: 'Named and hex colors', + }, + { + title: 'Style Tags', + text: '[b]Bold[/b] [i]Italic[/i] [u]Underline[/u] text', + description: 'Bold, italic, underline', + }, + { + title: 'Strikethrough', + text: '[s]Strikethrough text[/s] and normal text', + description: 'Strikethrough style', + }, + { + title: 'Nested Tags', + text: '[color=purple][u]Purple Bold[/u][/color] [color=orange][s]Orange Italic[/s][/color]', + description: 'Combined formatting', + }, + { + title: 'Mixed Decorations', + text: '[u][s]Under[color=red]lin[/color]e[/s][/u] and [s]strikethrough[/s] text', + description: 'Different text decorations', + }, + { + title: 'Complex Mix', + text: 'Hello [color=red]World[/color] [color=blue][s]Strike[/s][/color] [u]Underline[/u]!', + description: 'Multiple mixed tags', + }, + ]; + + examples.forEach((example, index) => { + const rowY = 80 + index * 50; + + // Create container for BBCode text + const textContainer = renderer.createNode({ + x: 30, + y: rowY, + w: 600, + h: 45, + color: 0x0066cc10, + parent: view, + }); + + // Add a subtle border + renderer.createNode({ + x: 0, + y: 0, + color: 0x0066cc40, + w: textContainer.w, + h: 2, + parent: textContainer, + }); + renderer.createNode({ + x: 0, + y: textContainer.h - 2, + w: textContainer.w, + h: 2, + color: 0x0066cc40, + parent: textContainer, + }); + + // Title label + renderer.createTextNode({ + text: example.title, + x: 650, + y: rowY, + fontSize: 14, + fontFamily, + fontStyle: 'normal', + color: 0x0066ccff, + parent: view, + }); + + // Description label + renderer.createTextNode({ + text: example.description, + x: 650, + y: rowY + 25, + fontSize: 12, + fontFamily, + color: 0x888888ff, + parent: view, + }); + + // BBCode formatted text + renderer.createTextNode({ + text: example.text, + x: 10, + y: 10, + fontSize, + fontFamily, + textRendererOverride: 'sdf', + color: 0x000000ff, // Default text color + parent: textContainer, + }); + + // // Raw BBCode display (what the user typed) + renderer.createTextNode({ + text: `Raw: ${example.text}`, + x: 850, + y: rowY + 45, + fontSize: 10, + fontFamily: 'monospace', + color: 0x666666ff, + maxWidth: 350, + parent: view, + }); + }); + + // Legend section + const legendY = 80 + examples.length * 50 + 20; + + renderer.createTextNode({ + text: 'Supported BBCode Tags:', + x: 30, + y: legendY, + fontSize: 18, + fontFamily, + color: 0x000000ff, + parent: view, + }); + + const tags = [ + '[color=name] or [color=#hex] - Text color', + '[b] - Bold text (TODO)', + '[i] - Italic text (TODO)', + '[u] - Underlined text', + '[s] - Strikethrough text', + ]; + + tags.forEach((tag, index) => { + renderer.createTextNode({ + text: `* ${tag}`, + x: 50, + y: legendY + 20 + index * 22, + fontSize: 15, + color: 0x000000ff, + parent: view, + }); + }); +} diff --git a/src/core/shaders/webgl/SdfShader.ts b/src/core/shaders/webgl/SdfShader.ts index 836230dd..1a0db983 100644 --- a/src/core/shaders/webgl/SdfShader.ts +++ b/src/core/shaders/webgl/SdfShader.ts @@ -77,6 +77,7 @@ export const Sdf: WebGlShaderType = { // It will receive data from a buffer attribute vec2 a_position; attribute vec2 a_textureCoords; + attribute vec4 a_color; uniform vec2 u_resolution; uniform mat3 u_transform; @@ -85,6 +86,7 @@ export const Sdf: WebGlShaderType = { uniform float u_size; varying vec2 v_texcoord; + varying vec4 v_color; void main() { vec2 scrolledPosition = a_position * u_size - vec2(0, u_scrollY); @@ -95,6 +97,7 @@ export const Sdf: WebGlShaderType = { gl_Position = vec4(screenSpace, 0.0, 1.0); v_texcoord = a_textureCoords; + v_color = a_color; } `, @@ -111,12 +114,20 @@ export const Sdf: WebGlShaderType = { uniform int u_debug; varying vec2 v_texcoord; + varying vec4 v_color; float median(float r, float g, float b) { return max(min(r, g), min(max(r, g), b)); } void main() { + // Check if this is an underline/strikethrough quad (UV coordinates are very close to 0,0) + if (length(v_texcoord) < 0.001) { + // Render as solid color for underlines/strikethroughs, use uniform color + gl_FragColor = vec4(u_color.r, u_color.g, u_color.b, u_color.a); + return; + } + vec3 sample = texture2D(u_texture, v_texcoord).rgb; if (u_debug == 1) { gl_FragColor = vec4(sample.r, sample.g, sample.b, 1.0); @@ -124,11 +135,22 @@ export const Sdf: WebGlShaderType = { } float scaledDistRange = u_distanceRange * u_pixelRatio; float sigDist = scaledDistRange * (median(sample.r, sample.g, sample.b) - 0.5); - float opacity = clamp(sigDist + 0.5, 0.0, 1.0) * u_color.a; + float opacity = clamp(sigDist + 0.5, 0.0, 1.0); + + // Check if we should use uniform color or per-vertex color + vec4 finalColor; + if (v_color.r < 0.0) { + finalColor = u_color; + } else { + // Use per-vertex color from BBCode + finalColor = v_color; + } + + opacity *= finalColor.a; // Build the final color. // IMPORTANT: We must premultiply the color by the alpha value before returning it. - gl_FragColor = vec4(u_color.r * opacity, u_color.g * opacity, u_color.b * opacity, opacity); + gl_FragColor = vec4(finalColor.r * opacity, finalColor.g * opacity, finalColor.b * opacity, opacity); } `, }; diff --git a/src/core/text-rendering/CanvasFontHandler.ts b/src/core/text-rendering/CanvasFontHandler.ts index fbd23662..5bec47ea 100644 --- a/src/core/text-rendering/CanvasFontHandler.ts +++ b/src/core/text-rendering/CanvasFontHandler.ts @@ -39,7 +39,7 @@ const fontFamilies: Record = {}; const loadedFonts = new Set(); const fontLoadPromises = new Map>(); const normalizedMetrics = new Map(); -const nodesWaitingForFont: Record = Object.create(null); +const nodesWaitingForFont: Record> = {}; let initialized = false; let context: CanvasRenderingContext2D | OffscreenCanvasRenderingContext2D; @@ -89,7 +89,8 @@ export const loadFont = async ( return existingPromise; } - const nwff: CoreTextNode[] = (nodesWaitingForFont[fontFamily] = []); + const nwff: Record = (nodesWaitingForFont[fontFamily] = + {}); // Create and store the loading promise const loadPromise = new FontFace(fontFamily, `url(${fontUrl})`) .load() @@ -102,7 +103,10 @@ export const loadFont = async ( setFontMetrics(fontFamily, normalizeMetrics(metrics)); } for (let key in nwff) { - nwff[key]!.setUpdateType(UpdateType.Local); + const node = nwff[key]; + if (node) { + node.setUpdateType(UpdateType.Local); + } } delete nodesWaitingForFont[fontFamily]; }) @@ -169,7 +173,10 @@ export const isFontLoaded = (fontFamily: string): boolean => { * @param node */ export const waitingForFont = (fontFamily: string, node: CoreTextNode) => { - nodesWaitingForFont[fontFamily]![node.id] = node; + if (!nodesWaitingForFont[fontFamily]) { + nodesWaitingForFont[fontFamily] = {}; + } + nodesWaitingForFont[fontFamily][node.id] = node; }; /** diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index 9839425c..0654c9eb 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -35,10 +35,10 @@ import type { WebGlCtxTexture } from '../renderers/webgl/WebGlCtxTexture.js'; import type { WebGlShaderNode } from '../renderers/webgl/WebGlShaderNode.js'; import { mergeColorAlpha } from '../../utils.js'; import type { TextLayout, GlyphLayout } from './TextRenderer.js'; -import { wrapText, measureLines } from './sdf/index.js'; +import { wrapText, measureLines, parseBBCode } from './sdf/index.js'; -// Each glyph requires 6 vertices (2 triangles) with 4 floats each (x, y, u, v) -const FLOATS_PER_VERTEX = 4; +// Each glyph requires 6 vertices (2 triangles) with 8 floats each (x, y, u, v, r, g, b, a) +const FLOATS_PER_VERTEX = 8; const VERTICES_PER_GLYPH = 6; // Type definition to match interface @@ -94,6 +94,135 @@ const renderText = (stage: Stage, props: CoreTextNodeProps): TextRenderInfo => { }; }; +/** + * Helper function to add a quad for underline/strikethrough with proper color + */ +const addSegmentQuadToBuffer = ( + vertexBuffer: Float32Array, + bufferIndex: number, + x1: number, + y1: number, + x2: number, + y2: number, + color?: number, +): number => { + let r: number, g: number, b: number, a: number; + + if (color !== undefined) { + // Use specific BBCode color for the segment + r = ((color >> 16) & 0xff) / 255.0; + g = ((color >> 8) & 0xff) / 255.0; + b = (color & 0xff) / 255.0; + a = 1.0; + } else { + // Use sentinel value (-1) to signal shader to use uniform color + r = -1.0; + g = -1.0; + b = -1.0; + a = -1.0; + } + + return addQuadToBuffer( + vertexBuffer, + bufferIndex, + x1, + y1, + x2, + y2, + 0.0, + 0.0, + 0.0, + 0.0, // No texture coordinates for solid color quads + r, + g, + b, + a, + ); +}; + +const addQuadToBuffer = ( + vertexBuffer: Float32Array, + bufferIndex: number, + x1: number, + y1: number, + x2: number, + y2: number, + u1: number, + v1: number, + u2: number, + v2: number, + r: number, + g: number, + b: number, + a: number, +): number => { + let index = bufferIndex; + + // Triangle 1: Top-left, top-right, bottom-left + // Vertex 1: Top-left + vertexBuffer[index++] = x1; + vertexBuffer[index++] = y1; + vertexBuffer[index++] = u1; + vertexBuffer[index++] = v1; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + // Vertex 2: Top-right + vertexBuffer[index++] = x2; + vertexBuffer[index++] = y1; + vertexBuffer[index++] = u2; + vertexBuffer[index++] = v1; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + // Vertex 3: Bottom-left + vertexBuffer[index++] = x1; + vertexBuffer[index++] = y2; + vertexBuffer[index++] = u1; + vertexBuffer[index++] = v2; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + // Triangle 2: Top-right, bottom-right, bottom-left + // Vertex 4: Top-right (duplicate) + vertexBuffer[index++] = x2; + vertexBuffer[index++] = y1; + vertexBuffer[index++] = u2; + vertexBuffer[index++] = v1; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + // Vertex 5: Bottom-right + vertexBuffer[index++] = x2; + vertexBuffer[index++] = y2; + vertexBuffer[index++] = u2; + vertexBuffer[index++] = v2; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + // Vertex 6: Bottom-left (duplicate) + vertexBuffer[index++] = x1; + vertexBuffer[index++] = y2; + vertexBuffer[index++] = u1; + vertexBuffer[index++] = v2; + vertexBuffer[index++] = r; + vertexBuffer[index++] = g; + vertexBuffer[index++] = b; + vertexBuffer[index++] = a; + + return index; +}; + /** * Add quads for rendering using cached layout data */ @@ -109,16 +238,35 @@ const addQuads = (layout?: TextLayout): Float32Array | null => { return null; } + // Count underline and strikethrough segments for additional quads + let decorationQuadCount = 0; + + // Count decoration segments by grouping consecutive decorated glyphs + for (let i = 0; i < glyphsLength; i++) { + const glyph = glyphs[i]; + const nextGlyph = glyphs[i + 1]; + + // If this is the end of an underline segment, count it + if (glyph?.underline === true && nextGlyph?.underline === false) { + decorationQuadCount++; + } + + // If this is the end of a strikethrough segment, count it + if (glyph?.strikethrough === true && nextGlyph?.strikethrough === false) { + decorationQuadCount++; + } + } + + const totalQuads = glyphsLength + decorationQuadCount; const vertexBuffer = new Float32Array( - glyphsLength * VERTICES_PER_GLYPH * FLOATS_PER_VERTEX, + totalQuads * VERTICES_PER_GLYPH * FLOATS_PER_VERTEX, ); let bufferIndex = 0; - let glyphIndex = 0; - while (glyphIndex < glyphsLength) { + // Add glyph quads first + for (let glyphIndex = 0; glyphIndex < glyphsLength; glyphIndex++) { const glyph = glyphs[glyphIndex]; - glyphIndex++; if (glyph === undefined) { continue; } @@ -133,43 +281,102 @@ const addQuads = (layout?: TextLayout): Float32Array | null => { const u2 = u1 + glyph.atlasWidth; const v2 = v1 + glyph.atlasHeight; - // Triangle 1: Top-left, top-right, bottom-left - // Vertex 1: Top-left - vertexBuffer[bufferIndex++] = x1; - vertexBuffer[bufferIndex++] = y1; - vertexBuffer[bufferIndex++] = u1; - vertexBuffer[bufferIndex++] = v1; - - // Vertex 2: Top-right - vertexBuffer[bufferIndex++] = x2; - vertexBuffer[bufferIndex++] = y1; - vertexBuffer[bufferIndex++] = u2; - vertexBuffer[bufferIndex++] = v1; - - // Vertex 3: Bottom-left - vertexBuffer[bufferIndex++] = x1; - vertexBuffer[bufferIndex++] = y2; - vertexBuffer[bufferIndex++] = u1; - vertexBuffer[bufferIndex++] = v2; - - // Triangle 2: Top-right, bottom-right, bottom-left - // Vertex 4: Top-right (duplicate) - vertexBuffer[bufferIndex++] = x2; - vertexBuffer[bufferIndex++] = y1; - vertexBuffer[bufferIndex++] = u2; - vertexBuffer[bufferIndex++] = v1; - - // Vertex 5: Bottom-right - vertexBuffer[bufferIndex++] = x2; - vertexBuffer[bufferIndex++] = y2; - vertexBuffer[bufferIndex++] = u2; - vertexBuffer[bufferIndex++] = v2; - - // Vertex 6: Bottom-left (duplicate) - vertexBuffer[bufferIndex++] = x1; - vertexBuffer[bufferIndex++] = y2; - vertexBuffer[bufferIndex++] = u1; - vertexBuffer[bufferIndex++] = v2; + // Extract color from glyph formatting or use uniform color + const glyphColor = glyph.color; + let r: number, g: number, b: number, a: number; + + if (glyphColor !== undefined) { + r = ((glyphColor >> 16) & 0xff) / 255.0; + g = ((glyphColor >> 8) & 0xff) / 255.0; + b = (glyphColor & 0xff) / 255.0; + a = 1.0; + } else { + r = -1.0; + g = -1.0; + b = -1.0; + a = -1.0; + } + + bufferIndex = addQuadToBuffer( + vertexBuffer, + bufferIndex, + x1, + y1, + x2, + y2, + u1, + v1, + u2, + v2, + r, + g, + b, + a, + ); + } + + // Add decoration segments + for (let glyphIndex = 0; glyphIndex < glyphsLength; glyphIndex++) { + const glyph = glyphs[glyphIndex]; + if (!glyph) continue; + + const nextGlyph = glyphs[glyphIndex + 1]; + + // Handle underline segments + if ( + glyph.underline === true && + (!nextGlyph || nextGlyph.underline === false) + ) { + let startIndex = glyphIndex; + while (startIndex > 0 && glyphs[startIndex - 1]?.underline === true) { + startIndex--; + } + + const startGlyph = glyphs[startIndex]; + const endGlyph = glyph; + if (!startGlyph) continue; + + const underlineY = glyph.y + glyph.height + 2; // Position slightly below text with small gap + const underlineThickness = Math.max(2, layout.lineHeight * 0.05); + + bufferIndex = addSegmentQuadToBuffer( + vertexBuffer, + bufferIndex, + startGlyph.x, + underlineY, + endGlyph.x + endGlyph.width, + underlineY + underlineThickness, + glyph.color, + ); + } + + // Handle strikethrough segments + if ( + glyph.strikethrough === true && + (!nextGlyph || nextGlyph.strikethrough === false) + ) { + let startIndex = glyphIndex; + while (startIndex > 0 && glyphs[startIndex - 1]?.strikethrough === true) { + startIndex--; + } + + const startGlyph = glyphs[startIndex]; + const endGlyph = glyph; + if (!startGlyph) continue; + + const strikethroughY = glyph.y + glyph.height * 0.45; // Position in middle of text + const strikethroughThickness = Math.max(2, layout.lineHeight * 0.04); + + bufferIndex = addSegmentQuadToBuffer( + vertexBuffer, + bufferIndex, + startGlyph.x, + strikethroughY, + endGlyph.x + endGlyph.width, + strikethroughY + strikethroughThickness, + glyph.color, + ); + } } return vertexBuffer; @@ -199,7 +406,7 @@ const renderQuads = ( // We can safely assume this is a WebGL renderer else this wouldn't be called const glw = (renderer as WebGlRenderer).glw; - const stride = 4 * Float32Array.BYTES_PER_ELEMENT; + const stride = 8 * Float32Array.BYTES_PER_ELEMENT; // 8 floats per vertex: x,y,u,v,r,g,b,a const webGlBuffer = glw.createBuffer(); if (!webGlBuffer) { @@ -227,6 +434,14 @@ const renderQuads = ( stride, offset: 2 * Float32Array.BYTES_PER_ELEMENT, }, + a_color: { + name: 'a_color', + size: 4, + type: glw.FLOAT as number, + normalized: false, + stride, + offset: 4 * Float32Array.BYTES_PER_ELEMENT, + }, }, }, ]); @@ -262,9 +477,36 @@ const renderQuads = ( 0, ); - // Add atlas texture and set quad count + let underlineSegmentCount = 0; + let inUnderlineSegment = false; + let strikethroughSegmentCount = 0; + let inStrikethroughSegment = false; + + for (let i = 0; i < layout.glyphs.length; i++) { + const glyph = layout.glyphs[i]; + if (glyph?.underline === true) { + if (!inUnderlineSegment) { + underlineSegmentCount++; + inUnderlineSegment = true; + } + } else { + inUnderlineSegment = false; + } + + if (glyph?.strikethrough === true) { + if (!inStrikethroughSegment) { + strikethroughSegmentCount++; + inStrikethroughSegment = true; + } + } else { + inStrikethroughSegment = false; + } + } + + const totalQuads = + layout.glyphs.length + underlineSegmentCount + strikethroughSegmentCount; renderOp.addTexture(atlasTexture.ctxTexture as WebGlCtxTexture); - renderOp.numQuads = layout.glyphs.length; + renderOp.numQuads = totalQuads; (renderer as WebGlRenderer).addRenderOp(renderOp); }; @@ -321,10 +563,14 @@ const generateTextLayout = ( const hasMaxLines = effectiveMaxLines > 0; + // Parse BBCode and create a character map with formatting information + const parsedText = parseBBCode(text); + const plainText = parsedText.text; + // Split text into lines based on wrapping constraints const [lines, remainingLines, remainingText] = shouldWrapText ? wrapText( - text, + plainText, fontFamily, finalScale, maxWidth, @@ -335,7 +581,7 @@ const generateTextLayout = ( hasMaxLines, ) : measureLines( - text.split('\n'), + plainText.split('\n'), fontFamily, letterSpacing, finalScale, @@ -348,14 +594,16 @@ const generateTextLayout = ( let currentY = 0; for (let i = 0; i < lines.length; i++) { - if (lines[i]![1] > maxWidthFound) { - maxWidthFound = lines[i]![1]; + const line = lines[i]; + if (line && line[1] > maxWidthFound) { + maxWidthFound = line[1]; } } // Second pass: Generate glyph layouts with proper alignment let lineIndex = 0; const linesLength = lines.length; + let globalCharIndex = 0; // Track position in original plain text for formatting lookup while (lineIndex < linesLength) { const [line, lineWidth] = lines[lineIndex]!; @@ -389,14 +637,24 @@ const generateTextLayout = ( continue; } + // Sync globalCharIndex with plainText by finding the next matching character + while ( + globalCharIndex < plainText.length && + plainText[globalCharIndex] !== char + ) { + globalCharIndex++; + } + // Skip zero-width spaces for rendering but keep them in the text flow if (isZeroWidthSpace(char)) { + globalCharIndex++; continue; } // Get glyph data from font handler const glyph = SdfFontHandler.getGlyph(fontFamily, codepoint); if (glyph === null) { + globalCharIndex++; continue; } @@ -413,6 +671,9 @@ const generateTextLayout = ( advance += kerning; } + // Get formatting for this character position + const formatting = parsedText.formatting[globalCharIndex]; + // Calculate glyph position and atlas coordinates (in design units) const glyphLayout: GlyphLayout = { codepoint, @@ -427,6 +688,9 @@ const generateTextLayout = ( atlasY: glyph.y / atlasHeight, atlasWidth: glyph.width / atlasWidth, atlasHeight: glyph.height / atlasHeight, + underline: formatting?.underline || false, + strikethrough: formatting?.strikethrough || false, + color: formatting?.color, }; glyphs.push(glyphLayout); @@ -434,6 +698,7 @@ const generateTextLayout = ( // Advance position with letter spacing (in design units) currentX += advance + designLetterSpacing; prevCodepoint = codepoint; + globalCharIndex++; } currentY += designLineHeight; diff --git a/src/core/text-rendering/TextRenderer.ts b/src/core/text-rendering/TextRenderer.ts index 67ad978f..5c06ca28 100644 --- a/src/core/text-rendering/TextRenderer.ts +++ b/src/core/text-rendering/TextRenderer.ts @@ -289,6 +289,18 @@ export interface GlyphLayout { atlasY: number; atlasWidth: number; atlasHeight: number; + /** + * Whether this glyph should be underlined + */ + underline?: boolean; + /** + * Whether this glyph should be struck through + */ + strikethrough?: boolean; + /** + * Color override for this glyph (0xRRGGBB format) + */ + color?: number; } /** diff --git a/src/core/text-rendering/sdf/Utils.ts b/src/core/text-rendering/sdf/Utils.ts index 86d48ec9..1ba6cf18 100644 --- a/src/core/text-rendering/sdf/Utils.ts +++ b/src/core/text-rendering/sdf/Utils.ts @@ -21,6 +21,187 @@ import { isZeroWidthSpace } from '../Utils.js'; import * as SdfFontHandler from '../SdfFontHandler.js'; import type { TextLineStruct, WrappedLinesStruct } from '../TextRenderer.js'; +/** + * BBCode formatting information for a character + */ +export interface CharacterFormatting { + underline?: boolean; + bold?: boolean; + italic?: boolean; + strikethrough?: boolean; + color?: number; // Color in 0xRRGGBB format +} + +/** + * Result of BBCode parsing + */ +export interface ParsedBBCode { + text: string; + formatting: Record; +} + +/** + * Parse color value from BBCode color attribute + * Supports hex colors (#ff0000, #f00) and named colors + */ +export const parseColor = (colorValue: string): number | null => { + const trimmed = colorValue.trim(); + + // Handle hex colors + if (trimmed.startsWith('#')) { + const hex = trimmed.substring(1); + + // Handle 3-digit hex (#f00 -> #ff0000) + if (hex.length === 3) { + const r = parseInt(hex.charAt(0) + hex.charAt(0), 16); + const g = parseInt(hex.charAt(1) + hex.charAt(1), 16); + const b = parseInt(hex.charAt(2) + hex.charAt(2), 16); + if (!isNaN(r) && !isNaN(g) && !isNaN(b)) { + return (r << 16) | (g << 8) | b; + } + } + + // Handle 6-digit hex (#ff0000) + if (hex.length === 6) { + const color = parseInt(hex, 16); + if (!isNaN(color)) { + return color; + } + } + } + + // Handle named colors + const namedColors: Record = { + red: 0xff0000, + green: 0x00ff00, + blue: 0x0000ff, + white: 0xffffff, + black: 0x000000, + yellow: 0xffff00, + cyan: 0x00ffff, + magenta: 0xff00ff, + orange: 0xff8000, + purple: 0x800080, + pink: 0xff69b4, + brown: 0xa52a2a, + gray: 0x808080, + grey: 0x808080, + }; + + const lowerName = trimmed.toLowerCase(); + if (namedColors[lowerName] !== undefined) { + return namedColors[lowerName]; + } + + return null; +}; + +/** + * BBCode parser that supports [u] for underline, [b] for bold, [i] for italic, [s] for strikethrough, and [color=value] for colors + * Returns plain text and character-level formatting information + */ +export const parseBBCode = (text: string): ParsedBBCode => { + const result: ParsedBBCode = { + text: '', + formatting: {}, + }; + + let currentFormatting: CharacterFormatting = {}; + let i = 0; + let outputIndex = 0; + + while (i < text.length) { + // Check for BBCode tags + if (text[i] === '[') { + let tagEnd = text.indexOf(']', i); + + if (tagEnd !== -1) { + const tag = text.substring(i + 1, tagEnd).toLowerCase(); + let isClosingTag = false; + let tagName = tag; + + if (tag.startsWith('/')) { + isClosingTag = true; + tagName = tag.substring(1); + } + + // Handle supported tags + if (tagName === 'u') { + if (isClosingTag) { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.underline; + } else { + currentFormatting = { ...currentFormatting, underline: true }; + } + i = tagEnd + 1; + continue; + } + // Add support for other tags if needed + else if (tagName === 'b') { + if (isClosingTag) { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.bold; + } else { + currentFormatting = { ...currentFormatting, bold: true }; + } + i = tagEnd + 1; + continue; + } else if (tagName === 'i') { + if (isClosingTag) { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.italic; + } else { + currentFormatting = { ...currentFormatting, italic: true }; + } + i = tagEnd + 1; + continue; + } else if (tagName === 's') { + if (isClosingTag) { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.strikethrough; + } else { + currentFormatting = { ...currentFormatting, strikethrough: true }; + } + i = tagEnd + 1; + continue; + } else if (tagName.startsWith('color=')) { + if (!isClosingTag) { + // Parse color value from tag like [color=#ff0000] or [color=red] + const colorValue = tagName.substring(6); // Remove 'color=' + const parsedColor = parseColor(colorValue); + if (parsedColor !== null) { + currentFormatting = { ...currentFormatting, color: parsedColor }; + } + } else { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.color; + } + i = tagEnd + 1; + continue; + } else if (tagName === 'color') { + // Handle closing [/color] tag + if (isClosingTag) { + currentFormatting = { ...currentFormatting }; + delete currentFormatting.color; + i = tagEnd + 1; + continue; + } + } + } + } + + // Regular character - add to output with current formatting + result.text += text[i]; + if (Object.keys(currentFormatting).length > 0) { + result.formatting[outputIndex] = { ...currentFormatting }; + } + outputIndex++; + i++; + } + + return result; +}; + export const measureLines = ( lines: string[], fontFamily: string, diff --git a/visual-regression/certified-snapshots/chromium-ci/text-bbcode-1.png b/visual-regression/certified-snapshots/chromium-ci/text-bbcode-1.png new file mode 100644 index 00000000..b491ec33 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-bbcode-1.png differ