diff --git a/examples/common/Pager.ts b/examples/common/Pager.ts new file mode 100644 index 00000000..b578a351 --- /dev/null +++ b/examples/common/Pager.ts @@ -0,0 +1,71 @@ +import type { INode, RendererMain } from '@lightningjs/renderer'; +import { Component } from './Component.js'; + +export default class Pager extends Component { + currentIndex = 0; + currentPage: INode | null = null; + pages: INode[] = []; + + constructor(renderer: RendererMain, parent: INode, pages: INode[]) { + super(renderer, { + w: 1920, + h: 1080, + parent, + }); + + this.pages = pages; + this.setPage(this.currentIndex); + this.addKeyboardNavigation(); + } + + setPage(index: number) { + if (this.currentPage) { + this.currentPage.parent = null; + } + if (this.pages[index] !== undefined) { + this.currentPage = this.pages[index]; + this.currentPage.parent = this.node; + } + return true; + } + + async nextPage(fromKeyboard = false) { + if (fromKeyboard === false && this.currentIndex === this.pages.length - 1) { + return false; + } + if (fromKeyboard === true) { + this.currentIndex = (this.currentIndex + 1) % this.pages.length; + } else { + this.currentIndex = Math.min( + this.pages.length - 1, + this.currentIndex + 1, + ); + } + this.setPage(this.currentIndex); + return true; + } + + async previousPage(fromKeyboard = false) { + if (fromKeyboard === false && this.currentIndex === 0) { + return false; + } + if (fromKeyboard === true) { + this.currentIndex = + (this.currentIndex - 1 + this.pages.length) % this.pages.length; + } else { + this.currentIndex = Math.max(0, this.currentIndex - 1); + } + this.setPage(this.currentIndex); + return true; + } + + addKeyboardNavigation() { + window.addEventListener('keydown', (e) => { + if (e.key === 'ArrowRight') { + this.nextPage(true); + } else if (e.key === 'ArrowLeft') { + this.previousPage(true); + } + }); + } +} diff --git a/examples/tests/alpha-blending.ts b/examples/tests/alpha-blending.ts index fb966fbc..5ee40d0c 100644 --- a/examples/tests/alpha-blending.ts +++ b/examples/tests/alpha-blending.ts @@ -85,6 +85,7 @@ export default async function test(settings: ExampleSettings) { fontFamily: 'Ubuntu', fontSize: HEADER_FONT_SIZE, color: 0xffffffff, + contain: 'width', maxWidth: renderer.settings.appWidth / 2, y: PADDING, textAlign: 'center', @@ -96,6 +97,7 @@ export default async function test(settings: ExampleSettings) { fontFamily: 'Ubuntu', fontSize: HEADER_FONT_SIZE, color: 0xffffffff, + contain: 'width', maxWidth: renderer.settings.appWidth / 2, x: renderer.settings.appWidth / 2, y: PADDING, diff --git a/examples/tests/text-align.ts b/examples/tests/text-align.ts index f1bffea0..4e0839e4 100644 --- a/examples/tests/text-align.ts +++ b/examples/tests/text-align.ts @@ -65,6 +65,7 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { const canvasText = renderer.createTextNode({ y: yPos, maxWidth: testRoot.w, + contain: 'width', fontSize, fontFamily, color: 0xff0000ff, @@ -74,6 +75,7 @@ export default async function test({ renderer, testRoot }: ExampleSettings) { const sdfText = renderer.createTextNode({ y: yPos, maxWidth: testRoot.w, + contain: 'width', fontSize, fontFamily, color: 0x0000ff77, diff --git a/examples/tests/text-contain-adv.ts b/examples/tests/text-contain-adv.ts new file mode 100644 index 00000000..094a514d --- /dev/null +++ b/examples/tests/text-contain-adv.ts @@ -0,0 +1,222 @@ +import type { INode } from '../../dist/exports/index.js'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; +import Pager from '../common/Pager.js'; + +export async function automation(settings: ExampleSettings) { + const pager = await test(settings); + await settings.snapshot(); + while (await pager.nextPage()) { + await settings.snapshot(); + } +} + +export default async function test(settings: ExampleSettings) { + const { renderer, testRoot } = settings; + + const createTextAlignPage = (align: 'left' | 'center' | 'right'): INode => { + const container = renderer.createNode({ + x: 0, + y: 0, + w: testRoot.w, + h: testRoot.h, + }); + + const centerVerticalLine = renderer.createNode({ + x: testRoot.w / 2, + y: 0, + w: 1, + h: testRoot.h, + color: 0xff0000ff, + parent: container, + }); + + const testLabel1 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 20, + mountX: 0.5, + text: `Text Align: ${align}, no maxWidth, no contain, mount: [0, .5, 1]`, + fontFamily: 'Ubuntu', + fontSize: 40, + color: 0x0000ffff, + textAlign: 'center', + parent: container, + }); + + const textNodeMount0 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 100, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const textNodeMount0_5 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 150, + mountX: 0.5, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const textNodeMount1 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 200, + mountX: 1, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const testLabel2 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 300, + mountX: 0.5, + text: `Text Align: ${align}, maxWidth, no contain, mount: [0, .5, 1]`, + fontFamily: 'Ubuntu', + fontSize: 40, + color: 0x0000ffff, + textAlign: 'center', + parent: container, + }); + + const textNodeMaxWidthMount0 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 400, + maxWidth: 400, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const textNodeMaxWidthMount0_5 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 450, + maxWidth: 400, + mountX: 0.5, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const textNodeMaxWidthMount1 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 500, + maxWidth: 400, + mountX: 1, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const testLabel3 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 600, + mountX: 0.5, + text: `Text Align: ${align}, maxWidth, contain, mount: [0, .5, 1]`, + fontFamily: 'Ubuntu', + fontSize: 40, + color: 0x0000ffff, + textAlign: 'center', + parent: container, + }); + + const controlNodeMount0 = renderer.createNode({ + x: testRoot.w / 2, + y: 700, + w: 400, + h: 30, + color: 0x00ff00ff, + parent: container, + }); + + const textNodeMaxWidthContainMount0 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 700, + maxWidth: 400, + contain: 'width', + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const controlNodeMount0_5 = renderer.createNode({ + x: testRoot.w / 2, + mountX: 0.5, + y: 750, + w: 400, + h: 30, + color: 0x00ff00ff, + parent: container, + }); + + const textNodeMaxWidthContainMount0_5 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 750, + maxWidth: 400, + contain: 'width', + mountX: 0.5, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + const controlNodeMount1 = renderer.createNode({ + x: testRoot.w / 2, + mountX: 1, + y: 800, + w: 400, + h: 30, + // rotation: (Math.PI * 2) / 16, + color: 0x00ff00ff, + parent: container, + }); + + const textNodeMaxWidthContainMount1 = renderer.createTextNode({ + x: testRoot.w / 2, + y: 800, + maxWidth: 400, + contain: 'width', + mountX: 1, + text: `Text align: ${align}`, + fontFamily: 'Ubuntu', + fontSize: 30, + // rotation: (Math.PI * 2) / 16, + color: 0x000000ff, + textAlign: align, + parent: container, + }); + + return container as INode; + }; + + return new Pager(renderer, testRoot, [ + createTextAlignPage('left'), + createTextAlignPage('center'), + createTextAlignPage('right'), + ]); +} diff --git a/examples/tests/text-contain.ts b/examples/tests/text-contain.ts index b36c845f..3783dda4 100644 --- a/examples/tests/text-contain.ts +++ b/examples/tests/text-contain.ts @@ -48,8 +48,6 @@ export default async function test(settings: ExampleSettings) { const text1 = renderer.createTextNode({ x: textSizeAfterLoadingBg.x, y: textSizeAfterLoadingBg.y, - w: 0, - h: 0, color: 0x000000ff, forceLoad: true, fontFamily: 'Ubuntu', @@ -65,8 +63,6 @@ Vivamus consectetur ex magna, non mollis.`, const text2 = renderer.createTextNode({ x: textSizeAfterLoadingBg.x, y: textSizeAfterLoadingBg.y, - w: 0, - h: 0, color: 0x000000ff, forceLoad: true, fontFamily: 'Ubuntu', @@ -164,7 +160,6 @@ Vivamus consectetur ex magna, non mollis.`, text1.alpha = 0; text2.alpha = 1; text2.maxWidth = 0; - text2.h = 0; }, () => { // Canvas, contain width @@ -202,9 +197,9 @@ Vivamus consectetur ex magna, non mollis.`, const targetText = i > 4 ? text2 : text1; header.text = makeHeader( - targetText.textRendererOverride!, - targetText.w, - targetText.h, + i > 4 ? 'canvas' : 'sdf', + targetText.maxWidth, + targetText.maxHeight, ); indexInfo.text = (i + 1).toString(); textSetDimsInfo.text = `Set size: ${Math.round(targetText.w)}x${Math.round( diff --git a/examples/tests/text-vertical-align.ts b/examples/tests/text-vertical-align.ts index 35923a29..de640880 100644 --- a/examples/tests/text-vertical-align.ts +++ b/examples/tests/text-vertical-align.ts @@ -93,12 +93,14 @@ function generateVerticalAlignTest( const nodeProps = { ...NODE_PROPS, text: 'txyz', + contain: 'height', textRendererOverride: textRenderer, maxHeight: CONTAINER_SIZE, } satisfies Partial; const baselineNode = renderer.createTextNode({ ...nodeProps, + verticalAlign: 'middle', }); return await constructTestRow({ renderer, rowNode }, [ @@ -130,11 +132,13 @@ function generateVerticalAlignTest( ...NODE_PROPS, text: 'abcd\ntxyz', textRendererOverride: textRenderer, + contain: 'height', maxHeight: CONTAINER_SIZE, } satisfies Partial; const baselineNode = renderer.createTextNode({ ...nodeProps, + verticalAlign: 'middle', }); return await constructTestRow({ renderer, rowNode }, [ diff --git a/examples/tests/text-wordbreak.ts b/examples/tests/text-wordbreak.ts index b7566ac5..36e7d25a 100644 --- a/examples/tests/text-wordbreak.ts +++ b/examples/tests/text-wordbreak.ts @@ -39,7 +39,7 @@ const NODE_PROPS = { textRendererOverride: 'sdf', fontSize: 20, maxWidth: containerSize, - wordBreak: 'normal', + wordBreak: 'overflow', } satisfies Partial; function generateWordBreakTest( diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index bc078937..ba938511 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -48,12 +48,20 @@ export interface CoreTextNodeProps extends CoreNodeProps, TrProps { forceLoad: boolean; } +export enum TextConstraint { + 'none' = 0, + 'width' = 1, + 'height' = 2, + 'both' = 4, +} + export class CoreTextNode extends CoreNode implements CoreTextNodeProps { private textRenderer: TextRenderer; private fontHandler: FontHandler; private _layoutGenerated = false; private _waitingForFont = false; + private _containType: TextConstraint = TextConstraint.none; // SDF layout caching for performance private _cachedLayout: TextLayout | null = null; @@ -82,6 +90,7 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { // Initialize text properties from props // Props are guaranteed to have all defaults resolved by Stage.createTextNode this.textProps = props; + this._containType = TextConstraint[props.contain]; this.setUpdateType(UpdateType.All); } @@ -118,32 +127,50 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { override updateLocalTransform() { const p = this.props; let { x, y, w, h } = p; - const mountTranslateX = p.mountX * w; - const mountTranslateY = p.mountY * h; + const mountX = p.mountX; + const mountY = p.mountY; + let mountTranslateX = p.mountX * w; + let mountTranslateY = p.mountY * h; + + let localTextTransform: Matrix3d | null = null; const tProps = this.textProps; const { textAlign, verticalAlign, maxWidth, maxHeight } = tProps; - - if (textAlign !== 'left' && maxWidth > 0) { - if (textAlign === 'right') { - x += maxWidth - w; - } else if (textAlign === 'center') { - x += (maxWidth - w) * 0.5; + const contain = this._containType; + + const hasMaxWidth = maxWidth > 0; + const hasMaxHeight = maxHeight > 0; + + if (contain > 0 && (hasMaxWidth || hasMaxHeight)) { + let containX = 0; + let containY = 0; + if (contain & TextConstraint.width && hasMaxWidth === true) { + if (textAlign === 'right') { + containX = maxWidth - w; + } else if (textAlign === 'center') { + containX = (maxWidth - w) * 0.5; + } + mountTranslateX = mountX * maxWidth; } - } - - if (verticalAlign !== 'top' && maxHeight > 0) { - if (verticalAlign === 'bottom') { - y += maxHeight - h; - } else if (verticalAlign === 'middle') { - y += (maxHeight - h) * 0.5; + if (contain & TextConstraint.height && maxHeight > 0) { + if (verticalAlign === 'bottom') { + containY = maxHeight - h; + } else if (verticalAlign === 'middle') { + containY = (maxHeight - h) * 0.5; + } + mountTranslateY = mountY * maxHeight; } + localTextTransform = Matrix3d.translate(containX, containY); } if (p.rotation !== 0 || p.scaleX !== 1 || p.scaleY !== 1) { const scaleRotate = Matrix3d.rotate(p.rotation).scale(p.scaleX, p.scaleY); - const pivotTranslateX = p.pivotX * w; - const pivotTranslateY = p.pivotY * h; + const pivotW = + contain & TextConstraint.width && maxWidth > 0 ? maxWidth : w; + const pivotH = + contain & TextConstraint.height && maxHeight > 0 ? maxHeight : h; + const pivotTranslateX = p.pivotX * pivotW; + const pivotTranslateY = p.pivotY * pivotH; this.localTransform = Matrix3d.translate( x - mountTranslateX + pivotTranslateX, @@ -159,6 +186,10 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { this.localTransform, ); } + + if (localTextTransform !== null) { + this.localTransform = this.localTransform.multiply(localTextTransform); + } } /** @@ -322,6 +353,24 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { super.destroy(); } + override set w(value: number) { + // dont allow direct setting of width on text nodes, handled by text layout generation + console.warn('Cannot directly set w on CoreTextNode'); + } + + override get w(): number { + return this.props.w; + } + + override set h(value: number) { + // dont allow direct setting of height on text nodes, handled by text layout generation + console.warn('Cannot directly set h on CoreTextNode'); + } + + override get h(): number { + return this.props.h; + } + get maxWidth() { return this.textProps.maxWidth; } @@ -347,6 +396,18 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { } } + get contain(): TrProps['contain'] { + return this.textProps.contain; + } + + set contain(value: TrProps['contain']) { + if (this.textProps.contain !== value) { + this.textProps.contain = value; + this._containType = TextConstraint[value]; + this.setUpdateType(UpdateType.Local); + } + } + get text(): string { return this.textProps.text; } diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 57ec1a57..44f52ef5 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -682,7 +682,8 @@ export class Stage { maxLines: props.maxLines || 0, verticalAlign: props.verticalAlign || 'top', overflowSuffix: props.overflowSuffix || '...', - wordBreak: props.wordBreak || 'normal', + wordBreak: props.wordBreak || 'break-word', + contain: props.contain || 'none', maxWidth: props.maxWidth || 0, maxHeight: props.maxHeight || 0, forceLoad: props.forceLoad || false, diff --git a/src/core/text-rendering/CanvasTextRenderer.ts b/src/core/text-rendering/CanvasTextRenderer.ts index 2ca72ca3..47ba4150 100644 --- a/src/core/text-rendering/CanvasTextRenderer.ts +++ b/src/core/text-rendering/CanvasTextRenderer.ts @@ -134,7 +134,6 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { metrics, text, textAlign, - verticalAlign, fontFamily, lineHeight, overflowSuffix, @@ -144,7 +143,6 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { maxWidth, maxHeight, ); - const lineAmount = lines.length; const canvasW = Math.ceil(effectiveWidth); const canvasH = Math.ceil(effectiveHeight); @@ -165,8 +163,8 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => { for (let i = 0; i < lineAmount; i++) { const line = lines[i] as TextLineStruct; const textLine = line[0]; - let currentX = Math.ceil(line[2]); - const currentY = Math.ceil(line[3]); + let currentX = Math.ceil(line[3]); + const currentY = Math.ceil(line[4]); if (letterSpacing === 0) { context.fillText(textLine, currentX, currentY); } else { diff --git a/src/core/text-rendering/SdfTextRenderer.ts b/src/core/text-rendering/SdfTextRenderer.ts index e1b5aba7..4d401fa3 100644 --- a/src/core/text-rendering/SdfTextRenderer.ts +++ b/src/core/text-rendering/SdfTextRenderer.ts @@ -308,7 +308,6 @@ const generateTextLayout = ( metrics, props.text, props.textAlign, - verticalAlign, fontFamily, lineHeight, props.overflowSuffix, @@ -329,9 +328,9 @@ const generateTextLayout = ( const textLine = line[0]; const textLineLength = textLine.length; let prevCodepoint = 0; - currentX = line[2]; + currentX = line[3]; //convert Y coord to vertex value - currentY = line[3] / fontScale; + currentY = line[4] / fontScale; for (let j = 0; j < textLineLength; j++) { const char = textLine.charAt(j); diff --git a/src/core/text-rendering/TextLayoutEngine.ts b/src/core/text-rendering/TextLayoutEngine.ts index 905687ce..d00c992a 100644 --- a/src/core/text-rendering/TextLayoutEngine.ts +++ b/src/core/text-rendering/TextLayoutEngine.ts @@ -7,6 +7,9 @@ import type { WrappedLinesStruct, } from './TextRenderer.js'; +// Use the same space regex as Canvas renderer to handle ZWSP +const spaceRegex = /[ \u200B]+/g; + export const defaultFontMetrics: FontMetrics = { ascender: 800, descender: -200, @@ -14,6 +17,24 @@ export const defaultFontMetrics: FontMetrics = { unitsPerEm: 1000, }; +type WrapStrategyFn = ( + measureText: MeasureTextFn, + word: string, + wordWidth: number, + fontFamily: string, + letterSpacing: number, + wrappedLines: TextLineStruct[], + currentLine: string, + currentLineWidth: number, + remainingLines: number, + remainingWord: string, + maxWidth: number, + space: string, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, +) => [string, number, string]; + export const normalizeFontMetrics = ( metrics: FontMetrics, fontSize: number, @@ -31,7 +52,6 @@ export const mapTextLayout = ( metrics: NormalizedFontMetrics, text: string, textAlign: string, - verticalAlign: string, fontFamily: string, lineHeight: number, overflowSuffix: string, @@ -51,13 +71,20 @@ export const mapTextLayout = ( const halfDelta = lineHeightDelta * 0.5; let effectiveMaxLines = maxLines; + if (maxHeight > 0) { - const maxFromHeight = Math.floor(maxHeight / lineHeightPx); + let maxFromHeight = Math.floor(maxHeight / lineHeightPx); + //ensure at least 1 line + if (maxFromHeight < 1) { + maxFromHeight = 1; + } if (effectiveMaxLines === 0 || maxFromHeight < effectiveMaxLines) { effectiveMaxLines = maxFromHeight; } } + //trim start/end whitespace + // text = text.trim(); const wrappedText = maxWidth > 0; //wrapText or just measureLines based on maxWidth const [lines, remainingLines, remainingText] = @@ -70,23 +97,26 @@ export const mapTextLayout = ( letterSpacing, overflowSuffix, wordBreak, - maxLines, + effectiveMaxLines, ) : measureLines( measureText, text.split('\n'), fontFamily, letterSpacing, - maxLines, + effectiveMaxLines, ); let effectiveLineAmount = lines.length; - let effectiveMaxWidth = lines[0]![1]; - - //check for longest line - if (effectiveLineAmount > 1) { - for (let i = 1; i < effectiveLineAmount; i++) { - effectiveMaxWidth = Math.max(effectiveMaxWidth, lines[i]![1]); + let effectiveMaxWidth = 0; + + if (effectiveLineAmount > 0) { + effectiveMaxWidth = lines[0]![1]; + //check for longest line + if (effectiveLineAmount > 1) { + for (let i = 1; i < effectiveLineAmount; i++) { + effectiveMaxWidth = Math.max(effectiveMaxWidth, lines[i]![1]); + } } } @@ -95,7 +125,7 @@ export const mapTextLayout = ( for (let i = 0; i < effectiveLineAmount; i++) { const line = lines[i]!; const w = line[1]; - line[2] = + line[3] = textAlign === 'right' ? effectiveMaxWidth - w : (effectiveMaxWidth - w) / 2; @@ -109,7 +139,7 @@ export const mapTextLayout = ( const startY = firstBaseLine; for (let i = 0; i < effectiveLineAmount; i++) { const line = lines[i] as TextLineStruct; - line[3] = startY + lineHeightPx * i; + line[4] = startY + lineHeightPx * i; } return [ @@ -142,7 +172,7 @@ export const measureLines = ( continue; } const width = measureText(line, fontFamily, letterSpacing); - measuredLines.push([line, width, 0, 0]); + measuredLines.push([line, width, false, 0, 0]); } return [ @@ -167,9 +197,10 @@ export const wrapText = ( // Calculate space width for line wrapping const spaceWidth = measureText(' ', fontFamily, letterSpacing); + const overflowWidth = measureText(overflowSuffix, fontFamily, letterSpacing); let wrappedLine: TextLineStruct[] = []; - let remainingLines = maxLines; + let remainingLines = maxLines > 0 ? maxLines : 1000; let hasRemainingText = true; let hasMaxLines = maxLines > 0; @@ -189,11 +220,11 @@ export const wrapText = ( letterSpacing, spaceWidth, overflowSuffix, + overflowWidth, wordBreak, remainingLines, - hasMaxLines, ) - : [[['', 0, 0, 0]], remainingLines, i < lines.length - 1]; + : [[['', 0, false, 0, 0]], remainingLines, i < lines.length - 1]; remainingLines--; wrappedLines.push(...wrappedLine); @@ -201,15 +232,23 @@ export const wrapText = ( if (hasMaxLines === true && remainingLines <= 0) { const lastLine = wrappedLines[wrappedLines.length - 1]!; if (i < lines.length - 1) { - if (lastLine[0].endsWith(overflowSuffix) === false) { - lastLine[0] = truncateLineWithSuffix( + //check if line is truncated already + if (lastLine[2] === false) { + let remainingText = ''; + const [line, lineWidth] = truncateLineEnd( measureText, - lastLine[0], fontFamily, - maxWidth, letterSpacing, + lastLine[0], + lastLine[1], + remainingText, + maxWidth, overflowSuffix, + overflowWidth, ); + lastLine[0] = line; + lastLine[1] = lineWidth; + lastLine[2] = true; } } break; @@ -227,12 +266,10 @@ export const wrapLine = ( letterSpacing: number, spaceWidth: number, overflowSuffix: string, + overflowWidth: number, wordBreak: string, remainingLines: number, - hasMaxLines: boolean, ): WrappedLinesStruct => { - // Use the same space regex as Canvas renderer to handle ZWSP - const spaceRegex = / |\u200B/g; const words = line.split(spaceRegex); const spaces = line.match(spaceRegex) || []; const wrappedLines: TextLineStruct[] = []; @@ -240,263 +277,396 @@ export const wrapLine = ( let currentLineWidth = 0; let hasRemainingText = true; - let i = 0; - - for (; i < words.length; i++) { - const word = words[i]; - if (word === undefined) { - continue; - } - const space = spaces[i - 1] || ''; - const wordWidth = measureText(word, fontFamily, letterSpacing); - // For width calculation, treat ZWSP as having 0 width but regular space functionality - const effectiveSpaceWidth = space === '\u200B' ? 0 : spaceWidth; - const totalWidth = currentLineWidth + effectiveSpaceWidth + wordWidth; - - if ( - (i === 0 && wordWidth <= maxWidth) || - (i > 0 && totalWidth <= maxWidth) - ) { - // Word fits on current line - if (currentLine.length > 0) { - // Add space - for ZWSP, don't add anything to output (it's invisible) - if (space !== '\u200B') { - currentLine += space; - currentLineWidth += effectiveSpaceWidth; - } - } - currentLine += word; - currentLineWidth += wordWidth; - } else { - if (remainingLines === 1) { - if (currentLine.length > 0) { - // Add space - for ZWSP, don't add anything to output (it's invisible) - if (space !== '\u200B') { - currentLine += space; - currentLineWidth += effectiveSpaceWidth; - } + const wrapFn = getWrapStrategy(wordBreak); + while (words.length > 0 && remainingLines > 0) { + let word = words.shift()!; + let wordWidth = measureText(word, fontFamily, letterSpacing); + let remainingWord = ''; + + //handle first word of new line separately to avoid empty line issues + if (currentLineWidth === 0) { + // Word doesn't fit on current line + //if first word doesn't fit on empty line + if (wordWidth > maxWidth) { + remainingLines--; + //truncate word to fit + [word, remainingWord, wordWidth] = + remainingLines === 0 + ? truncateWord( + measureText, + word, + wordWidth, + maxWidth, + fontFamily, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord( + measureText, + word, + wordWidth, + maxWidth, + fontFamily, + letterSpacing, + ); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); } - currentLine += word; - currentLineWidth += wordWidth; - remainingLines = 0; - hasRemainingText = i < words.length; - break; - } - - if (wordBreak !== 'break-all' && currentLine.length > 0) { - wrappedLines.push([currentLine, currentLineWidth, 0, 0]); - } - - if (wordBreak !== 'break-all') { + // first word doesn't fit on an empty line + wrappedLines.push([word, wordWidth, false, 0, 0]); + } else if (wordWidth + spaceWidth >= maxWidth) { remainingLines--; + // word with space doesn't fit, but word itself fits - put on new line + wrappedLines.push([word, wordWidth, false, 0, 0]); + } else { currentLine = word; currentLineWidth = wordWidth; } + continue; + } + const space = spaces.shift() || ''; + // For width calculation, treat ZWSP as having 0 width but regular space functionality + const effectiveSpaceWidth = space === '\u200B' ? 0 : spaceWidth; + const totalWidth = currentLineWidth + effectiveSpaceWidth + wordWidth; - if (wordBreak === 'break-word') { - const [lines, rl, rt] = breakWord( - measureText, - word, - fontFamily, - maxWidth, - letterSpacing, - remainingLines, - ); - remainingLines = rl; - hasRemainingText = rt; - if (lines.length === 1) { - [currentLine, currentLineWidth] = lines[lines.length - 1]!; - } else { - for (let j = 0; j < lines.length; j++) { - [currentLine, currentLineWidth] = lines[j]!; - if (j < lines.length - 1) { - wrappedLines.push(lines[j]!); - } - } - } - } else if (wordBreak === 'break-all') { - const firstLetterWidth = measureText( - word.charAt(0), - fontFamily, - letterSpacing, - ); - let linebreak = false; - if ( - currentLineWidth + firstLetterWidth + effectiveSpaceWidth > - maxWidth - ) { - wrappedLines.push([currentLine, currentLineWidth, 0, 0]); - remainingLines -= 1; - currentLine = ''; - currentLineWidth = 0; - linebreak = true; - } - const initial = maxWidth - currentLineWidth; - const [lines, rl, rt] = breakAll( - measureText, - word, - fontFamily, - initial, - maxWidth, - letterSpacing, - remainingLines, - ); - remainingLines = rl; - hasRemainingText = rt; - if (linebreak === false) { - const [text, width] = lines[0]!; - currentLine += text; - currentLineWidth += width; - wrappedLines.push([currentLine, currentLineWidth, 0, 0]); - } - - for (let j = 1; j < lines.length; j++) { - [currentLine, currentLineWidth] = lines[j]!; - if (j < lines.length - 1) { - wrappedLines.push([currentLine, currentLineWidth, 0, 0]); - } - } + if (totalWidth < maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + currentLineWidth = totalWidth; + continue; + } + // Will move to next line after loop finishes + remainingLines--; - if (i < words.length - 1 && currentLine.endsWith(' ') === false) { - currentLine += ' '; - currentLineWidth += effectiveSpaceWidth; - } - } + if (totalWidth === maxWidth) { + currentLine += effectiveSpaceWidth > 0 ? space + word : word; + currentLineWidth = totalWidth; + wrappedLines.push([currentLine, currentLineWidth, false, 0, 0]); + currentLine = ''; + currentLineWidth = 0; + continue; } - } - // Add the last line if it has content - if (currentLine.length > 0 && hasMaxLines === true && remainingLines === 0) { - currentLine = truncateLineWithSuffix( + [currentLine, currentLineWidth, remainingWord] = wrapFn( measureText, - currentLine, + word, + wordWidth, fontFamily, - maxWidth, letterSpacing, + wrappedLines, + currentLine, + currentLineWidth, + remainingLines, + remainingWord, + maxWidth, + space, + spaceWidth, overflowSuffix, + overflowWidth, ); + + if (remainingWord.length > 0) { + words.unshift(remainingWord); + } } - if (currentLine.length > 0) { - wrappedLines.push([currentLine, currentLineWidth, 0, 0]); + if (currentLineWidth > 0 && remainingLines > 0) { + wrappedLines.push([currentLine, currentLineWidth, false, 0, 0]); } + return [wrappedLines, remainingLines, hasRemainingText]; }; +const getWrapStrategy = (wordBreak: string): WrapStrategyFn => { + //** default so probably first out */ + if (wordBreak === 'break-word') { + return breakWord; + } + //** second most used */ + if (wordBreak === 'break-all') { + return breakAll; + } + //** most similar to html/CSS 'normal' not really used in TV apps */ + if (wordBreak === 'overflow') { + return overflow; + } + //fallback + return breakWord; +}; + +//break strategies + /** - * Truncate a line with overflow suffix to fit within width + * Overflow wordBreak strategy, if a word partially fits add it to the line, start new line if necessary or add overflowSuffix. + * + * @remarks This strategy is similar to 'normal' in html/CSS. However */ -export const truncateLineWithSuffix = ( +export const overflow = ( measureText: MeasureTextFn, - line: string, + word: string, + wordWidth: number, fontFamily: string, - maxWidth: number, letterSpacing: number, + wrappedLines: TextLineStruct[], + currentLine: string, + currentLineWidth: number, + remainingLines: number, + remainingWord: string, + maxWidth: number, + space: string, + spaceWidth: number, overflowSuffix: string, -): string => { - const suffixWidth = measureText(overflowSuffix, fontFamily, letterSpacing); - - if (suffixWidth >= maxWidth) { - return overflowSuffix.substring(0, Math.max(1, overflowSuffix.length - 1)); - } - - let truncatedLine = line; - while (truncatedLine.length > 0) { - const lineWidth = measureText(truncatedLine, fontFamily, letterSpacing); - if (lineWidth + suffixWidth <= maxWidth) { - return truncatedLine + overflowSuffix; - } - truncatedLine = truncatedLine.substring(0, truncatedLine.length - 1); + overflowWidth: number, +): [string, number, string] => { + currentLine += space + word; + currentLineWidth += spaceWidth + wordWidth; + + if (remainingLines === 0) { + currentLine += overflowSuffix; + currentLineWidth += overflowWidth; } - return overflowSuffix; + wrappedLines.push([currentLine, currentLineWidth, true, 0, 0]); + return ['', 0, '']; }; -/** - * wordbreak function: https://developer.mozilla.org/en-US/docs/Web/CSS/word-break#break-word - */ export const breakWord = ( measureText: MeasureTextFn, word: string, + wordWidth: number, fontFamily: string, + letterSpacing: number, + wrappedLines: TextLineStruct[], + currentLine: string, + currentLineWidth: number, + remainingLines: number, + remainingWord: string, maxWidth: number, + space: string, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, +): [string, number, string] => { + remainingWord = word; + if (remainingLines === 0) { + [currentLine, currentLineWidth, remainingWord] = truncateLineEnd( + measureText, + fontFamily, + letterSpacing, + currentLine, + currentLineWidth, + remainingWord, + maxWidth, + overflowSuffix, + overflowWidth, + ); + wrappedLines.push([currentLine, currentLineWidth, true, 0, 0]); + } else { + wrappedLines.push([currentLine, currentLineWidth, false, 0, 0]); + currentLine = ''; + currentLineWidth = 0; + } + return [currentLine, currentLineWidth, remainingWord]; +}; + +export const breakAll = ( + measureText: MeasureTextFn, + word: string, + wordWidth: number, + fontFamily: string, letterSpacing: number, + wrappedLines: TextLineStruct[], + currentLine: string, + currentLineWidth: number, remainingLines: number, -): WrappedLinesStruct => { - const lines: TextLineStruct[] = []; - let currentPart = ''; - let currentWidth = 0; - let i = 0; + remainingWord: string, + maxWidth: number, + space: string, + spaceWidth: number, + overflowSuffix: string, + overflowWidth: number, +): [string, number, string] => { + let remainingSpace = maxWidth - currentLineWidth; + if (currentLineWidth > 0) { + remainingSpace -= spaceWidth; + } + const truncate = remainingLines === 0; + [word, remainingWord, wordWidth] = truncate + ? truncateWord( + measureText, + word, + wordWidth, + remainingSpace, + fontFamily, + letterSpacing, + overflowSuffix, + overflowWidth, + ) + : splitWord( + measureText, + word, + wordWidth, + remainingSpace, + fontFamily, + letterSpacing, + ); + currentLine += space + word; + currentLineWidth += spaceWidth + wordWidth; + + // first word doesn't fit on an empty line + wrappedLines.push([currentLine, currentLineWidth, truncate, 0, 0]); + + currentLine = ''; + currentLineWidth = 0; + + return [currentLine, currentLineWidth, remainingWord]; +}; - for (let i = 0; i < word.length; i++) { - const char = word.charAt(i); - const codepoint = char.codePointAt(0); - if (codepoint === undefined) continue; +export const truncateLineEnd = ( + measureText: MeasureTextFn, + fontFamily: string, + letterSpacing: number, + currentLine: string, + currentLineWidth: number, + remainingWord: string, + maxWidth: number, + overflowSuffix: string, + overflowWidth: number, +): [string, number, string] => { + if (currentLineWidth + overflowWidth <= maxWidth) { + currentLine += overflowSuffix; + currentLineWidth += overflowWidth; + remainingWord = ''; + return [currentLine, currentLineWidth, remainingWord]; + } + let truncated = false; + for (let i = currentLine.length - 1; i > 0; i--) { + const char = currentLine.charAt(i); const charWidth = measureText(char, fontFamily, letterSpacing); - - if (currentWidth + charWidth > maxWidth && currentPart.length > 0) { - remainingLines--; - if (remainingLines === 0) { - break; - } - lines.push([currentPart, currentWidth, 0, 0]); - currentPart = char; - currentWidth = charWidth; - } else { - currentPart += char; - currentWidth += charWidth; + currentLineWidth -= charWidth; + if (currentLineWidth + overflowWidth <= maxWidth) { + currentLine = currentLine.substring(0, i) + overflowSuffix; + currentLineWidth += overflowWidth; + remainingWord = currentLine.substring(i) + ' ' + remainingWord; + truncated = true; + break; } } - if (currentPart.length > 0) { - lines.push([currentPart, currentWidth, 0, 0]); + if (truncated === false) { + currentLine = overflowSuffix; + currentLineWidth = overflowWidth; + remainingWord = currentLine; } - - return [lines, remainingLines, i < word.length - 1]; + return [currentLine, currentLineWidth, remainingWord]; }; -/** - * wordbreak function: https://developer.mozilla.org/en-US/docs/Web/CSS/word-break#break-word - */ -export const breakAll = ( +export const truncateWord = ( measureText: MeasureTextFn, word: string, - fontFamily: string, - initial: number, + wordWidth: number, maxWidth: number, + fontFamily: string, letterSpacing: number, - remainingLines: number, -): WrappedLinesStruct => { - const lines: TextLineStruct[] = []; - let currentPart = ''; - let currentWidth = 0; - let max = initial; - let i = 0; - let hasRemainingText = false; + overflowSuffix: string, + overflowWidth: number, +): [string, string, number] => { + const targetWidth = maxWidth - overflowWidth; - for (; i < word.length; i++) { - if (remainingLines === 0) { - hasRemainingText = true; - break; + if (targetWidth <= 0) { + return ['', word, 0]; + } + + const excessWidth = wordWidth - targetWidth; + // If excess is small (< 50%), we're keeping most - start from back and remove + // If excess is large (>= 50%), we're removing most - start from front and add + const shouldStartFromBack = excessWidth < wordWidth / 2; + + if (shouldStartFromBack === false) { + // Start from back - remove characters until it fits (keeping most of word) + let currentWidth = wordWidth; + for (let i = word.length - 1; i > 0; i--) { + const char = word.charAt(i); + const charWidth = measureText(char, fontFamily, letterSpacing); + currentWidth -= charWidth; + if (currentWidth <= targetWidth) { + const remainingWord = word.substring(i); + return [ + word.substring(0, i) + overflowSuffix, + remainingWord, + currentWidth + overflowWidth, + ]; + } } + // Even first character doesn't fit + return [overflowSuffix, word, overflowWidth]; + } + + // Start from front - add characters until we exceed limit (removing most of word) + let currentWidth = 0; + for (let i = 0; i < word.length; i++) { const char = word.charAt(i); const charWidth = measureText(char, fontFamily, letterSpacing); - if (currentWidth + charWidth > max && currentPart.length > 0) { - lines.push([currentPart, currentWidth, 0, 0]); - currentPart = char; - currentWidth = charWidth; - max = maxWidth; - remainingLines--; - } else { - currentPart += char; - currentWidth += charWidth; + if (currentWidth + charWidth > targetWidth) { + const remainingWord = word.substring(i); + return [ + word.substring(0, i) + overflowSuffix, + remainingWord, + currentWidth + overflowWidth, + ]; } + currentWidth += charWidth; + } + // Entire word fits (shouldn't happen, but safe fallback) + return [word + overflowSuffix, '', wordWidth + overflowWidth]; +}; + +export const splitWord = ( + measureText: MeasureTextFn, + word: string, + wordWidth: number, + maxWidth: number, + fontFamily: string, + letterSpacing: number, +): [string, string, number] => { + if (maxWidth <= 0) { + return ['', word, 0]; } - if (currentPart.length > 0) { - lines.push([currentPart, currentWidth, 0, 0]); + const excessWidth = wordWidth - maxWidth; + // If excess is small (< 50%), we're keeping most - start from back and remove + // If excess is large (>= 50%), we're removing most - start from front and add + const shouldStartFromBack = excessWidth < wordWidth / 2; + + if (shouldStartFromBack === false) { + // Start from back - remove characters until it fits (keeping most of word) + let currentWidth = wordWidth; + for (let i = word.length - 1; i > 0; i--) { + const char = word.charAt(i); + const charWidth = measureText(char, fontFamily, letterSpacing); + currentWidth -= charWidth; + if (currentWidth <= maxWidth) { + const remainingWord = word.substring(i); + return [word.substring(0, i), remainingWord, currentWidth]; + } + } + // Even first character doesn't fit + return ['', word, 0]; } - return [lines, remainingLines, hasRemainingText]; + // Start from front - add characters until we exceed limit (removing most of word) + let currentWidth = 0; + for (let i = 0; i < word.length; i++) { + const char = word.charAt(i); + const charWidth = measureText(char, fontFamily, letterSpacing); + if (currentWidth + charWidth > maxWidth) { + const remainingWord = word.substring(i); + return [word.substring(0, i), remainingWord, currentWidth]; + } + currentWidth += charWidth; + } + // Entire word fits (shouldn't happen, but safe fallback) + return [word, '', wordWidth]; }; diff --git a/src/core/text-rendering/TextRenderer.ts b/src/core/text-rendering/TextRenderer.ts index 65163122..ad97cada 100644 --- a/src/core/text-rendering/TextRenderer.ts +++ b/src/core/text-rendering/TextRenderer.ts @@ -225,15 +225,29 @@ export interface TrProps extends TrFontProps { * @remarks * This property sets how words should break when reaching the end of a line. * - * - `'normal'`: Use the default line break rule. + * - `'overflow'`: Uses the Css/HTML normal word-break behavior, generally not used in app development. * - `'break-all'`: To prevent overflow, word breaks should happen between any two characters. * - `'break-word'`: To prevent overflow, word breaks should happen between words. If words are too long word breaks happen between any two characters. * - * @default "normal" + * @default "break-word" */ - wordBreak: 'normal' | 'break-all' | 'break-word'; + wordBreak: 'overflow' | 'break-all' | 'break-word'; - zIndex: number; + /** + * contain mode for text + * + * @remarks + * + * This property sets how the text should be contained within its bounding box. + * + * - 'width': The text is contained within the specified maxWidth, horizontal position of text will adjust according to {@link textAlign}. + * - 'height': The text is contained within the specified maxHeight, vertical position of text will adjust according to {@link verticalAlign}. + * - 'both': The text is contained within both the specified maxWidth and maxHeight. + * - 'none': The text is not contained within any bounding box. + * + * @default 'none' + */ + contain: 'width' | 'height' | 'both' | 'none'; } /** @@ -395,10 +409,11 @@ export interface TextRenderer { * Text line struct for text mapping * 0 - text * 1 - width - * 2 - line offset x - * 3 - line offset y + * 2 - truncated + * 3 - line offset x + * 4 - line offset y */ -export type TextLineStruct = [string, number, number, number]; +export type TextLineStruct = [string, number, boolean, number, number]; /** * Wrapped lines struct for text mapping diff --git a/src/core/text-rendering/tests/Canvas.test.ts b/src/core/text-rendering/tests/Canvas.test.ts deleted file mode 100644 index 13b4bec1..00000000 --- a/src/core/text-rendering/tests/Canvas.test.ts +++ /dev/null @@ -1,378 +0,0 @@ -/* - * 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 { describe, it, expect } from 'vitest'; - -import { - wrapText, - wrapLine, - truncateLineWithSuffix, - breakWord, -} from '../TextLayoutEngine.js'; -import { hasZeroWidthSpace } from '../Utils'; - -// Test-specific measureText function that mimics testMeasureText behavior -const testMeasureText = ( - text: string, - fontFamily: string, - letterSpacing: number, -): number => { - //ignoring this without context available - // if (letterSpacing === 0) { - // return measureContext.measureText(text).width; - // } - if (text.indexOf(' ') === -1 && hasZeroWidthSpace(text) === false) { - return (10 + letterSpacing) * text.length; - } - return text.split('').reduce((acc, char) => { - if (hasZeroWidthSpace(char) === true) { - return acc; - } - let width = 10; - if (char === ' ') { - width = 5; - } - return acc + width + letterSpacing; - }, 0); -}; - -describe('Canvas Text Utils', () => { - describe('measureText', () => { - it('should measure text width correctly', () => { - const width = testMeasureText('hello', 'Arial', 0); - expect(width).toBe(50); // 5 characters * 10px each - }); - - it('should handle empty text', () => { - const width = testMeasureText('', 'Arial', 0); - expect(width).toBe(0); - }); - - it('should account for letter spacing', () => { - const width = testMeasureText('hello', 'Arial', 2); - expect(width).toBe(60); // 5 characters * 10px + 5 * 2 letter spacing - }); - - it('should skip zero-width spaces in letter spacing calculation', () => { - const width = testMeasureText('hel\u200Blo', 'Arial', 2); - // With letter spacing=2: 'h'(10) + 2 + 'e'(10) + 2 + 'l'(10) + 2 + ZWSP(0) + 'l'(10) + 2 + 'o'(10) = 60 - // The ZWSP is in the string but gets 0 width, letter spacing is still added for non-ZWSP chars - expect(width).toBe(60); - }); - - it('should handle spaces correctly', () => { - const width = testMeasureText('hi there', 'Arial', 0); - // With space=0, uses context.measureText() directly - // Mock returns: 'h'(10) + 'i'(10) + ' '(5) + 't'(10) + 'h'(10) + 'e'(10) + 'r'(10) + 'e'(10) = 75px - expect(width).toBe(75); - }); - }); - - describe('wrapLine', () => { - it('should wrap text that exceeds max width', () => { - const result = wrapLine( - testMeasureText, // Add measureText as first parameter - 'hello world test', - 'Arial', - 100, // maxWidth (10 characters at 10 units each) - 0, // designLetterSpacing - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - - const [lines] = result; - expect(lines).toHaveLength(2); - expect(lines[0]?.[0]).toEqual('hello'); // Break at space, not ZWSP - expect(lines[1]?.[0]).toEqual('world test'); - }); - - it('should handle single word that fits', () => { - const result = wrapLine( - testMeasureText, - 'hello', - 'Arial', - 100, - 0, - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - expect(result[0][0]).toEqual(['hello', 50, 0, 0]); // 4-element format - }); - - it('should break long words', () => { - const result = wrapLine( - testMeasureText, - 'verylongwordthatdoesnotfit', - 'Arial', - 100, // Only 10 characters fit (each char = 10 units) - 0, - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - const [lines] = result; // Extract the lines array - // The implementation returns the full word when wordBreak is 'normal' (default behavior) - // This is correct behavior - single words are not broken unless wordBreak is set to 'break-all' - expect(lines.length).toBe(1); - expect(lines[0]?.[0]).toBe('verylongwordthatdoesnotfit'); - }); - - it('should handle ZWSP as word break opportunity', () => { - // Test 1: ZWSP should provide break opportunity when needed - const result1 = wrapLine( - testMeasureText, - 'hello\u200Bworld test', - 'Arial', - 100, // 10 characters max - 'helloworld' = 100 units (fits), ' test' = 50 units (exceeds) - 0, - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - - const [lines] = result1; - expect(lines[0]?.[0]).toEqual('helloworld'); // Break at space, not ZWSP - expect(lines[1]?.[0]).toEqual('test'); - - // Test 2: ZWSP should NOT break when text fits on one line - const result2 = wrapLine( - testMeasureText, - 'hi\u200Bthere', - 'Arial', - 200, // Wide enough for all text (7 characters = 70 units) - 0, - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - expect(result2[0][0]).toEqual(['hithere', 70, 0, 0]); // ZWSP is invisible, no space added - - // Test 3: ZWSP should break when it's the only break opportunity - const result3 = wrapLine( - testMeasureText, - 'verylongword\u200Bmore', - 'Arial', - 100, // 10 characters max - forces break at ZWSP - 0, - 10, // spaceWidth - '', - 'normal', - 0, - false, - ); - expect(result3.length).toBeGreaterThan(1); // Should break at ZWSP position - expect(result3[0][0]).toEqual(['verylongword', 120, 0, 0]); - }); - - it('should truncate with suffix when max lines reached', () => { - const result = wrapLine( - testMeasureText, - 'hello world test more and even more text that exceeds limits', - 'Arial', - 200, // Wide enough to force multiple words on one line - 0, - 10, // spaceWidth - '...', - 'normal', - 0, // remainingLines = 0 - this should trigger truncation when hasMaxLines is true - true, // hasMaxLines = true - this enables truncation - ); - // With the current implementation, text wraps naturally across multiple lines - // when remainingLines is 0 and hasMaxLines is true, but doesn't truncate in this case - // This behavior is correct for the text layout engine - expect(result[0].length).toBeGreaterThan(1); - expect(result[0][0]?.[0]).toBe('hello world test'); - }); - }); - - describe('wrapText', () => { - it('should wrap multiple lines', () => { - const result = wrapText( - testMeasureText, - 'line one\nline two that is longer', - 'Arial', - 100, - 0, - '', - 'normal', - 0, - ); - expect(result[0].length).toBeGreaterThan(2); - expect(result[0][0]).toStrictEqual(['line one', 75, 0, 0]); - }); - - it('should handle empty lines', () => { - const result = wrapText( - testMeasureText, - 'line one\n\nline three', - 'Arial', - 100, - 0, - '', - 'normal', - 0, - ); - expect(result[0][1]?.[0]).toBe(''); - }); - - it('should respect max lines limit', () => { - const result = wrapText( - testMeasureText, - 'line one\\nline two\\nline three\\nline four', - 'Arial', - 100, - 0, - '', - 'normal', - 2, // maxLines = 2 - ); - const [lines] = result; - expect(lines).toHaveLength(2); - }); - }); - - describe('truncateLineWithSuffix', () => { - it('should truncate line and add suffix', () => { - const result = truncateLineWithSuffix( - testMeasureText, - 'this is a very long line', - 'Arial', - 100, // Max width for 10 characters - 0, - '...', - ); - expect(result).toContain('...'); - expect(result.length).toBe(11); - }); - - it('should return suffix if suffix is too long', () => { - const result = truncateLineWithSuffix( - testMeasureText, - 'hello', - 'Arial', - 30, // Only 3 characters fit - 0, - 'verylongsuffix', - ); - expect(result).toMatch(/verylongsuffi/); // Truncated suffix - }); - - it('should return original line with suffix (current behavior)', () => { - // Note: The current implementation always adds the suffix, even if the line fits. - // This is the expected behavior when used in overflow contexts where the suffix - // indicates that content was truncated at the line limit. - const result = truncateLineWithSuffix( - testMeasureText, - 'short', - 'Arial', - 100, - 0, - '...', - ); - expect(result).toBe('short...'); - }); - }); - - describe('breakLongWord', () => { - it('should break word into multiple lines', () => { - const result = breakWord( - testMeasureText, - 'verylongword', - 'Arial', - 50, // 5 characters max per line - 0, - 0, - ); - expect(result[0].length).toBeGreaterThan(1); - expect(result[0][0]?.[0]).toHaveLength(5); - }); - - it('should handle single character word', () => { - const result = breakWord(testMeasureText, 'a', 'Arial', 50, 0, 0); - expect(result[0][0]).toStrictEqual(['a', 10, 0, 0]); - }); - - it('should truncate with suffix when max lines reached', () => { - const result = breakWord( - testMeasureText, - 'verylongword', - 'Arial', - 50, - 0, - 1, // remainingLines = 1 - ); - expect(result[0]).toHaveLength(1); - }); - - it('should handle empty word', () => { - const result = breakWord(testMeasureText, '', 'Arial', 50, 0, 0); - expect(result[0]).toEqual([]); - }); - }); - - describe('Integration tests', () => { - it('should handle complex text with ZWSP and wrapping', () => { - const text = - 'This is a test\u200Bwith zero-width\u200Bspaces that should wrap properly'; - const result = wrapText( - testMeasureText, - text, - 'Arial', - 200, // 20 characters max per line - 0, - '...', - 'normal', - 0, - ); - expect(result.length).toBeGreaterThan(1); - const [lines] = result; - // Should split at ZWSP and regular spaces - expect(lines.some((line) => line[0].includes('zero-width'))).toBe(true); - }); - - it('should handle mixed content with long words and ZWSP', () => { - const text = 'Short\u200Bverylongwordthatmustbebroken\u200Bshort'; - const result = wrapText( - testMeasureText, - text, - 'Arial', - 100, // 10 characters max per line - 0, - '', - 'normal', - 0, - ); - expect(result.length).toBeGreaterThan(2); - expect(result[0][0]?.[0]).toBe('Short'); - expect(result[0][result.length - 1]?.[0]).toBe('short'); - }); - }); -}); diff --git a/src/core/text-rendering/tests/SdfTests.test.ts b/src/core/text-rendering/tests/TextLayoutEngine.test.ts similarity index 79% rename from src/core/text-rendering/tests/SdfTests.test.ts rename to src/core/text-rendering/tests/TextLayoutEngine.test.ts index c4e777d9..4f43645d 100644 --- a/src/core/text-rendering/tests/SdfTests.test.ts +++ b/src/core/text-rendering/tests/TextLayoutEngine.test.ts @@ -21,8 +21,8 @@ import { describe, it, expect } from 'vitest'; import { wrapText, wrapLine, - truncateLineWithSuffix, breakWord, + truncateLineEnd, } from '../TextLayoutEngine.js'; // Mock font data for testing @@ -130,9 +130,9 @@ describe('SDF Text Utils', () => { 0, // designLetterSpacing 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 10, ); const [lines] = result; @@ -146,15 +146,15 @@ describe('SDF Text Utils', () => { testMeasureText, 'hello', 'Arial', - 100, - 0, + 100, // maxWidth (10 characters at 10 units each) + 0, // designLetterSpacing 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 1, ); - expect(result[0][0]).toEqual(['hello', 50, 0, 0]); // 4-element format + expect(result[0][0]).toEqual(['hello', 50, false, 0, 0]); // 4-element format }); it('should break long words', () => { @@ -162,19 +162,19 @@ describe('SDF Text Utils', () => { testMeasureText, 'verylongwordthatdoesnotfit', 'Arial', - 100, // Only 10 characters fit (each char = 10 units) - 0, + 100, // maxWidth (10 characters at 10 units each) + 0, // designLetterSpacing 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 1, ); const [lines] = result; // Extract the lines array // The implementation returns the full word when wordBreak is 'normal' (default behavior) // This is correct behavior - single words are not broken unless wordBreak is set to 'break-all' expect(lines.length).toBe(1); - expect(lines[0]?.[0]).toBe('verylongwordthatdoesnotfit'); + expect(lines[0]?.[0]).toBe('verylongwo'); }); it('should handle ZWSP as word break opportunity', () => { @@ -183,13 +183,13 @@ describe('SDF Text Utils', () => { testMeasureText, 'hello\u200Bworld test', 'Arial', - 100, // 10 characters max - 'helloworld' = 100 units (fits), ' test' = 50 units (exceeds) - 0, + 100, // maxWidth (10 characters at 10 units each) + 0, // designLetterSpacing 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 2, ); const [lines] = result1; @@ -201,15 +201,15 @@ describe('SDF Text Utils', () => { testMeasureText, 'hi\u200Bthere', 'Arial', - 200, // Wide enough for all text (7 characters = 70 units) - 0, + 200, // maxWidth + 0, // designLetterSpacing 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 1, ); - expect(result2[0][0]).toEqual(['hithere', 70, 0, 0]); // ZWSP is invisible, no space added + expect(result2[0][0]).toEqual(['hithere', 70, false, 0, 0]); // ZWSP is invisible, no space added // Test 3: ZWSP should break when it's the only break opportunity const result3 = wrapLine( @@ -220,12 +220,12 @@ describe('SDF Text Utils', () => { 0, 10, // spaceWidth '', - 'normal', - 0, - false, + 0, //overflowWidth + 'break-word', + 2, ); expect(result3.length).toBeGreaterThan(1); // Should break at ZWSP position - expect(result3[0][0]).toEqual(['verylongword', 120, 0, 0]); + expect(result3[0][0]).toEqual(['verylongwo', 100, false, 0, 0]); }); it('should truncate with suffix when max lines reached', () => { @@ -237,9 +237,9 @@ describe('SDF Text Utils', () => { 0, 10, // spaceWidth '...', - 'normal', - 0, // remainingLines = 0 - this should trigger truncation when hasMaxLines is true - true, // hasMaxLines = true - this enables truncation + 0, //overflowWidth + 'break-word', + 10, // remainingLines = 0 - this should trigger truncation when hasMaxLines is true ); // With the current implementation, text wraps naturally across multiple lines // when remainingLines is 0 and hasMaxLines is true, but doesn't truncate in this case @@ -262,7 +262,7 @@ describe('SDF Text Utils', () => { 0, ); expect(result[0].length).toBeGreaterThan(2); - expect(result[0][0]).toStrictEqual(['line one', 80, 0, 0]); + expect(result[0][0]).toStrictEqual(['line one', 80, false, 0, 0]); }); it('should handle empty lines', () => { @@ -297,43 +297,52 @@ describe('SDF Text Utils', () => { describe('truncateLineWithSuffix', () => { it('should truncate line and add suffix', () => { - const result = truncateLineWithSuffix( + const result = truncateLineEnd( testMeasureText, - 'this is a very long line', 'Arial', - 100, // Max width for 10 characters 0, - '...', + 'this is a very long line', //current line + 240, // current line width + '', + 100, // Max width for 10 characters + '...', // Suffix + 30, // Suffix width ); - expect(result).toContain('...'); + expect(result[0]).toContain('...'); expect(result.length).toBeLessThanOrEqual(10); }); it('should return suffix if suffix is too long', () => { - const result = truncateLineWithSuffix( + const result = truncateLineEnd( testMeasureText, - 'hello', 'Arial', - 30, // Only 3 characters fit 0, + 'hello', + 50, // current line width + '', + 30, // Only 3 characters fit 'verylongsuffix', + 140, // Suffix width ); - expect(result).toMatch(/verylongsuffi/); // Truncated suffix + expect(result[0]).toMatch(/verylongsuffi/); // Truncated suffix }); it('should return original line with suffix (current behavior)', () => { // Note: The current implementation always adds the suffix, even if the line fits. // This is the expected behavior when used in overflow contexts where the suffix // indicates that content was truncated at the line limit. - const result = truncateLineWithSuffix( + const result = truncateLineEnd( testMeasureText, - 'short', 'Arial', - 100, 0, + 'short', + 50, // 5 characters fit + '', + 40, '...', + 30, ); - expect(result).toBe('short...'); + expect(result[0]).toBe('s...'); }); }); @@ -342,35 +351,64 @@ describe('SDF Text Utils', () => { const result = breakWord( testMeasureText, 'verylongword', + 'verylongword'.length * 10, 'Arial', - 50, // 5 characters max per line 0, + [], + '', + 0, + 1, + '', + 50, // 5 characters max per line + '', 0, + '...', + 30, ); - expect(result[0].length).toBeGreaterThan(1); - expect(result[0][0]?.[0]).toHaveLength(5); + expect(result.length).toBeGreaterThan(1); + expect(result[2]).toHaveLength(12); }); it('should handle single character word', () => { - const result = breakWord(testMeasureText, 'a', 'Arial', 50, 0, 0); - expect(result[0][0]).toStrictEqual(['a', 10, 0, 0]); + const result = breakWord( + testMeasureText, + 'a', + 10, + 'Arial', + 0, + [], + '', + 0, + 1, + '', + 50, // 5 characters max per line + '', + 0, + '...', + 30, + ); + expect(result).toStrictEqual(['', 0, 'a']); }); it('should truncate with suffix when max lines reached', () => { const result = breakWord( testMeasureText, 'verylongword', + 'verylongword'.length * 10, 'Arial', - 50, 0, - 1, // remainingLines = 1 + [], + '', + 0, + 1, + '', + 50, // 5 characters max per line + '', + 0, + '...', + 30, ); - expect(result[0]).toHaveLength(1); - }); - - it('should handle empty word', () => { - const result = breakWord(testMeasureText, '', 'Arial', 50, 0, 0); - expect(result[0]).toEqual([]); + expect(result[0]).toHaveLength(0); }); }); @@ -406,9 +444,10 @@ describe('SDF Text Utils', () => { 'normal', 0, ); - expect(result.length).toBeGreaterThan(2); - expect(result[0][0]?.[0]).toBe('Short'); - expect(result[0][result.length - 1]?.[0]).toBe('short'); + const [lines] = result; + expect(lines.length).toBeGreaterThan(2); + expect(lines[0]?.[0]).toBe('Short'); + expect(lines[lines.length - 1]?.[0]).toBe('short'); }); }); }); diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-1.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-1.png index 1e183c23..3ada06b7 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-10.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-10.png index 8ade225d..131ae29b 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-10.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-10.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-2.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-2.png index 04526409..604270c6 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-2.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-3.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-3.png index 35be71e4..094c0efa 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-3.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-3.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-4.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-4.png index 9562944f..e7fc23ed 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-4.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-4.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-5.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-5.png index 5e761649..3157f828 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-5.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-5.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-6.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-6.png index a18a03ef..2063f072 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-6.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-6.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-7.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-7.png index c068e0ae..ce7fc38d 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-7.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-7.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-8.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-8.png index 21775b06..750b7222 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-8.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-8.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-9.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-9.png index 1276d88e..c9d38e8f 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-contain-9.png and b/visual-regression/certified-snapshots/chromium-ci/text-contain-9.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-1.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-1.png new file mode 100644 index 00000000..82a2345c Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-2.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-2.png new file mode 100644 index 00000000..a1c5c4aa Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-3.png b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-3.png new file mode 100644 index 00000000..3f534a26 Binary files /dev/null and b/visual-regression/certified-snapshots/chromium-ci/text-contain-adv-3.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png index cc3ffea5..dfb5ec91 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png index 93c56f52..55ff0345 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png and b/visual-regression/certified-snapshots/chromium-ci/text-max-lines-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png b/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png index 1441e63f..920a8449 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-overflow-suffix-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png index 7f70d5d6..f8926343 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png index 498c99e5..dddad02d 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png and b/visual-regression/certified-snapshots/chromium-ci/text-vertical-align-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-1.png b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-1.png index 276ea6cc..375f8d4b 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-1.png and b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-1.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-2.png b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-2.png index 80aae731..e2a061ec 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-2.png and b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-2.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-3.png b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-3.png index 12fffe88..e7de5f57 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-3.png and b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-3.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-4.png b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-4.png index 02c939df..c30f697c 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-4.png and b/visual-regression/certified-snapshots/chromium-ci/text-wordbreak-4.png differ diff --git a/visual-regression/certified-snapshots/chromium-ci/zIndex-1.png b/visual-regression/certified-snapshots/chromium-ci/zIndex-1.png index 00a0ec9f..044a3a13 100644 Binary files a/visual-regression/certified-snapshots/chromium-ci/zIndex-1.png and b/visual-regression/certified-snapshots/chromium-ci/zIndex-1.png differ