@lightningjs/renderer
Version:
Lightning 3 Renderer
474 lines (436 loc) • 13.2 kB
text/typescript
// /*
// * If not stated otherwise in this file or this component's LICENSE file the
// * following copyright and licenses apply:
// *
// * Copyright 2025 Comcast Cable 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,
breakWord,
truncateLineEnd,
} from '../TextLayoutEngine.js';
// Mock font data for testing
// Mock SdfFontHandler functions
const mockGetGlyph = (_fontFamily: string, codepoint: number) => {
// Mock glyph data - each character is 10 units wide for easy testing
return {
id: codepoint,
char: String.fromCharCode(codepoint),
x: 0,
y: 0,
width: 10,
height: 16,
xoffset: 0,
yoffset: 0,
xadvance: 10,
page: 0,
chnl: 0,
};
};
const mockGetKerning = () => {
// No kerning for simplicity
return 0;
};
// Test-specific measureText function that mimics testMeasureText behavior
// but works with our mocked getGlyph and getKerning functions
const testMeasureText = (
text: string,
fontFamily: string,
letterSpacing: number,
): number => {
if (text.length === 1) {
const char = text.charAt(0);
const codepoint = text.codePointAt(0);
if (codepoint === undefined) return 0;
if (char === '\u200B') return 0; // Zero-width space
const glyph = mockGetGlyph(fontFamily, codepoint);
if (glyph === null) return 0;
return glyph.xadvance + letterSpacing;
}
let width = 0;
let prevCodepoint = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charAt(i);
const codepoint = text.codePointAt(i);
if (codepoint === undefined) continue;
// Skip zero-width spaces in width calculations
if (char === '\u200B') {
continue;
}
const glyph = mockGetGlyph(fontFamily, codepoint);
if (glyph === null) continue;
let advance = glyph.xadvance;
// Add kerning if there's a previous character
if (prevCodepoint !== 0) {
const kerning = mockGetKerning();
advance += kerning;
}
width += advance + letterSpacing;
prevCodepoint = codepoint;
}
return width;
};
// Mock measureText function to replace the broken SDF implementation
describe('SDF Text Utils', () => {
describe('measureText', () => {
it('should return correct width for basic text', () => {
const width = testMeasureText('hello', 'Arial', 0);
expect(width).toBeCloseTo(50); // 5 chars * 10 xadvance
});
it('should return 0 width for empty text', () => {
const width = testMeasureText('', 'Arial', 0);
expect(width).toBe(0);
});
it('should include letter spacing in width calculation', () => {
const width = testMeasureText('hello', 'Arial', 2);
expect(width).toBeCloseTo(60); // 5 chars * (10 xadvance + 2 letterSpacing)
});
it('should skip zero-width spaces in width calculation', () => {
const width = testMeasureText('hel\u200Blo', 'Arial', 0);
expect(width).toBeCloseTo(50); // Should be same as 'hello'
});
});
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
'',
0, //overflowWidth
'break-word',
10,
);
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, // maxWidth (10 characters at 10 units each)
0, // designLetterSpacing
10, // spaceWidth
'',
0, //overflowWidth
'break-word',
1,
);
expect(result[0][0]).toEqual(['hello', 50, false, 0, 0]); // 4-element format
});
it('should break long words', () => {
const result = wrapLine(
testMeasureText,
'verylongwordthatdoesnotfit',
'Arial',
100, // maxWidth (10 characters at 10 units each)
0, // designLetterSpacing
10, // spaceWidth
'',
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('verylongwo');
});
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, // maxWidth (10 characters at 10 units each)
0, // designLetterSpacing
10, // spaceWidth
'',
0, //overflowWidth
'break-word',
2,
);
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, // maxWidth
0, // designLetterSpacing
10, // spaceWidth
'',
0, //overflowWidth
'break-word',
1,
);
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(
testMeasureText,
'verylongword\u200Bmore',
'Arial',
100, // 10 characters max - forces break at ZWSP
0,
10, // spaceWidth
'',
0, //overflowWidth
'break-word',
2,
);
expect(result3.length).toBeGreaterThan(1); // Should break at ZWSP position
expect(result3[0][0]).toEqual(['verylongwo', 100, false, 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
'...',
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
// This behavior is correct for the text layout engine
expect(result[0].length).toBeGreaterThan(1);
expect(result[0][0]?.[0]).toBe('hello world test');
});
it('should return non-empty output when maxWidth is smaller than glyph and suffix width', () => {
const result = wrapLine(
testMeasureText,
'hello',
'Arial',
5,
0,
10,
'...',
30,
'break-word',
1,
);
expect(result[0]).toHaveLength(1);
expect(result[0][0]?.[0]).toBe('...');
expect(result[0][0]?.[2]).toBe(true);
expect(result[1]).toBe(0);
});
});
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', 80, false, 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 = truncateLineEnd(
testMeasureText,
'Arial',
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[0]).toContain('...');
expect(result.length).toBeLessThanOrEqual(10);
});
it('should return suffix if suffix is too long', () => {
const result = truncateLineEnd(
testMeasureText,
'Arial',
0,
'hello',
50, // current line width
'',
30, // Only 3 characters fit
'verylongsuffix',
140, // Suffix width
);
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 = truncateLineEnd(
testMeasureText,
'Arial',
0,
'short',
50, // 5 characters fit
'',
40,
'...',
30,
);
expect(result[0]).toBe('s...');
});
});
describe('breakLongWord', () => {
it('should break word into multiple lines', () => {
const result = breakWord(
testMeasureText,
'verylongword',
'verylongword'.length * 10,
'Arial',
0,
[],
'',
0,
1,
'',
50, // 5 characters max per line
'',
0,
'...',
30,
);
expect(result.length).toBeGreaterThan(1);
expect(result[2]).toHaveLength(12);
});
it('should handle single character word', () => {
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',
0,
[],
'',
0,
1,
'',
50, // 5 characters max per line
'',
0,
'...',
30,
);
expect(result[0]).toHaveLength(0);
});
});
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,
);
const [lines] = result;
expect(lines.length).toBeGreaterThan(2);
expect(lines[0]?.[0]).toBe('Short');
expect(lines[lines.length - 1]?.[0]).toBe('short');
});
});
});