diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts new file mode 100644 index 0000000..eacaef2 --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -0,0 +1,99 @@ +import { + layoutWithLines, + prepareWithSegments, +} from '@chenglou/pretext'; + +/** + * High-performance text layout engine using pretext + */ +export interface LayoutLine { + text: string; + width: number; + chars: Array<{ + char: string; + x: number; + width: number; + }>; +} + +export interface LayoutResult { + lines: LayoutLine[]; + totalHeight: number; +} + +export class TextLayoutEngine { + #segmenter: Intl.Segmenter; + + constructor(locale?: string) { + // Use Intl.Segmenter for grapheme-level segmentation + // Pretext uses this internally, so we align with it. + this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); + } + + /** + * Measure and layout text within a given width + */ + layout(text: string, font: string, width: number, lineHeight: number): LayoutResult { + if (!text) { + return { lines: [], totalHeight: 0 }; + } + + // Use prepareWithSegments to get segment information + const prepared = prepareWithSegments(text, font); + const { lines, height } = layoutWithLines(prepared, width, lineHeight); + + // Access internal pretext data for character-level offsets + const internal = prepared as any; + const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[]; + const widths = internal.widths as number[]; + + const resultLines: LayoutLine[] = lines.map(line => { + const chars: LayoutLine['chars'] = []; + let currentX = 0; + + const start = line.start; + const end = line.end; + + // Iterate through segments in the line + for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { + const segmentText = prepared.segments[sIdx]; + if (segmentText === undefined) continue; + + // Get graphemes for this segment + const segments = this.#segmenter.segment(segmentText); + const graphemes = Array.from(segments, s => s.segment); + + // Get widths/advances for graphemes in this segment + const advances = breakableFitAdvances[sIdx]; + + const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0; + const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length; + + for (let gIdx = gStart; gIdx < gEnd; gIdx++) { + const char = graphemes[gIdx]; + // advances is null for single-grapheme or non-breakable segments. + // In both cases the whole segment width belongs to the single grapheme. + const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!; + + chars.push({ + char, + x: currentX, + width: charWidth, + }); + currentX += charWidth; + } + } + + return { + text: line.text, + width: line.width, + chars, + }; + }); + + return { + lines: resultLines, + totalHeight: height, + }; + } +} diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts new file mode 100644 index 0000000..7aeac46 --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { installCanvasMock } from '../__mocks__/canvas'; +import { TextLayoutEngine } from './TextLayoutEngine.svelte'; + +// Fixed-width mock: every segment is measured as (text.length * 10) px. +// This is font-independent so we can reason about wrapping precisely. +const CHAR_WIDTH = 10; + +describe('TextLayoutEngine', () => { + let engine: TextLayoutEngine; + + beforeEach(() => { + // Install mock BEFORE any prepareWithSegments call. + // clearMeasurementCaches resets pretext's cached canvas context + // and segment metric caches so each test gets a clean slate. + installCanvasMock((_font, text) => text.length * CHAR_WIDTH); + clearCache(); + engine = new TextLayoutEngine(); + }); + + it('returns empty result for empty string', () => { + const result = engine.layout('', '400 16px "Inter"', 500, 20); + expect(result.lines).toHaveLength(0); + expect(result.totalHeight).toBe(0); + }); + + it('returns a single line when text fits within width', () => { + // 'ABC' = 3 chars × 10px = 30px, fits in 500px + const result = engine.layout('ABC', '400 16px "Inter"', 500, 20); + expect(result.lines).toHaveLength(1); + expect(result.lines[0].text).toBe('ABC'); + }); + + it('breaks text into multiple lines when it exceeds width', () => { + // 'Hello World' — pretext will split at the space. + // 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '. + const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20); + expect(result.lines.length).toBeGreaterThan(1); + // First line must not exceed the container width. + expect(result.lines[0].width).toBeLessThanOrEqual(60); + }); + + it('assigns correct x positions to characters on a single line', () => { + // 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container. + const result = engine.layout('ABC', '400 16px "Inter"', 500, 20); + const chars = result.lines[0].chars; + + expect(chars).toHaveLength(3); + expect(chars[0].char).toBe('A'); + expect(chars[0].x).toBe(0); + expect(chars[0].width).toBe(CHAR_WIDTH); + + expect(chars[1].char).toBe('B'); + expect(chars[1].x).toBe(CHAR_WIDTH); + expect(chars[1].width).toBe(CHAR_WIDTH); + + expect(chars[2].char).toBe('C'); + expect(chars[2].x).toBe(CHAR_WIDTH * 2); + expect(chars[2].width).toBe(CHAR_WIDTH); + }); + + it('x positions are monotonically increasing across a line', () => { + const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20); + const chars = result.lines[0].chars; + for (let i = 1; i < chars.length; i++) { + expect(chars[i].x).toBeGreaterThan(chars[i - 1].x); + } + }); + + it('each line has at least one char', () => { + const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20); + for (const line of result.lines) { + expect(line.chars.length).toBeGreaterThan(0); + } + }); + + it('totalHeight equals lineCount * lineHeight', () => { + const lineHeight = 24; + const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight); + expect(result.totalHeight).toBe(result.lines.length * lineHeight); + }); +});