import { layoutWithLines, prepareWithSegments, } from '@chenglou/pretext'; /** * A single laid-out line of text, with per-grapheme x offsets and widths. * * `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji * sequences and combining characters each produce exactly one entry. */ export interface LayoutLine { /** Full text of this line as returned by pretext. */ text: string; /** Rendered width of this line in pixels. */ width: number; chars: Array<{ /** The grapheme cluster string (may be >1 code unit for emoji, etc.). */ char: string; /** X offset from the start of the line, in pixels. */ x: number; /** Advance width of this grapheme, in pixels. */ width: number; }>; } export interface LayoutResult { lines: LayoutLine[]; /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ totalHeight: number; } /** * Single-font text layout engine backed by `@chenglou/pretext`. * * Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where * only one font is needed. For dual-font comparison use `CharacterComparisonEngine`. * * **Usage** * ```ts * const engine = new TextLayoutEngine(); * const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24); * // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...] * ``` * * **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`. * This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`. * * **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on * first use and caches the context for the process lifetime. Tests must install a canvas mock * (see `__mocks__/canvas.ts`) before the first `layout()` call. */ export class TextLayoutEngine { /** * Grapheme segmenter used to split segment text into individual clusters. * * Pretext maintains its own internal segmenter for line-breaking decisions. * We keep a separate one here so we can iterate graphemes in `layout()` * without depending on pretext internals — the two segmenters produce * identical boundaries because both use `{ granularity: 'grapheme' }`. */ #segmenter: Intl.Segmenter; constructor(locale?: string) { this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); } /** * Lay out `text` in the given `font` within `width` pixels. * * @param text Raw text to lay out. * @param font CSS font string: `"weight sizepx \"family\""`. * @param width Available line width in pixels. * @param lineHeight Line height in pixels (passed directly to pretext). * @returns Per-line grapheme data. Empty `lines` when `text` is empty. */ layout(text: string, font: string, width: number, lineHeight: number): LayoutResult { if (!text) { return { lines: [], totalHeight: 0 }; } // prepareWithSegments measures the text and builds the segment data structure // (widths, breakableFitAdvances, etc.) that the line-walker consumes. const prepared = prepareWithSegments(text, font); const { lines, height } = layoutWithLines(prepared, width, lineHeight); // `PreparedTextWithSegments` has these fields in its public type definition // but the TypeScript signature only exposes `segments`. We cast to `any` to // access the parallel numeric arrays — they are documented in the plan and // verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts. 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; // Walk every segment that falls within this line's [start, end] cursors. // Both cursors are grapheme-level: start is inclusive, end is exclusive. for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { const segmentText = prepared.segments[sIdx]; if (segmentText === undefined) continue; const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); const advances = breakableFitAdvances[sIdx]; // For the first and last segments of the line the cursor may point // into the middle of the segment — respect those boundaries. // All intermediate segments are walked in full (gStart=0, gEnd=length). 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]; // `breakableFitAdvances[sIdx]` is an array of per-grapheme advance // widths when the segment has >1 grapheme (multi-character words). // It is `null` for single-grapheme segments (spaces, punctuation, // emoji, etc.) — in that case the entire segment width is attributed // to this 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, // pretext guarantees height === lineCount * lineHeight (see layout.ts source). totalHeight: height, }; } }