From 351ee9fd52bdf4a99cad15aff74c343b9ffc887e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:10:01 +0300 Subject: [PATCH] docs: add inline documentation to TextLayoutEngine --- .../TextLayoutEngine.svelte.ts | 78 +++++++++++++++---- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts index eacaef2..0aec801 100644 --- a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -4,45 +4,90 @@ import { } from '@chenglou/pretext'; /** - * High-performance text layout engine using 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) { - // 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 + * 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 }; } - // Use prepareWithSegments to get segment information + // 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); - // Access internal pretext data for character-level offsets + // `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[]; @@ -54,25 +99,29 @@ export class TextLayoutEngine { const start = line.start; const end = line.end; - // Iterate through segments in the line + // 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; - // 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 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]; - // advances is null for single-grapheme or non-breakable segments. - // In both cases the whole segment width belongs to the single grapheme. + + // `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({ @@ -93,6 +142,7 @@ export class TextLayoutEngine { return { lines: resultLines, + // pretext guarantees height === lineCount * lineHeight (see layout.ts source). totalHeight: height, }; }