diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts index 1d0dc65..2f36bf1 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts @@ -5,25 +5,64 @@ import { } from '@chenglou/pretext'; /** - * Result of dual-font comparison layout + * A single laid-out line produced by dual-font comparison layout. + * + * Line breaking is determined by the unified worst-case widths, so both fonts + * always break at identical positions. Per-character `xA`/`xB` offsets reflect + * each font's actual advance widths independently. */ export interface ComparisonLine { + /** Full text of this line as returned by pretext. */ text: string; - width: number; // Max width between font A and B + /** Rendered width of this line in pixels — maximum across font A and font B. */ + width: number; chars: Array<{ + /** The grapheme cluster string (may be >1 code unit for emoji, etc.). */ char: string; - xA: number; // X offset in font A + /** X offset from the start of the line in font A, in pixels. */ + xA: number; + /** Advance width of this grapheme in font A, in pixels. */ widthA: number; - xB: number; // X offset in font B + /** X offset from the start of the line in font B, in pixels. */ + xB: number; + /** Advance width of this grapheme in font B, in pixels. */ widthB: number; }>; } +/** + * Aggregated output of a dual-font layout pass. + */ export interface ComparisonResult { + /** Per-line grapheme data for both fonts. Empty when input text is empty. */ lines: ComparisonLine[]; + /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ totalHeight: number; } +/** + * Dual-font text layout engine backed by `@chenglou/pretext`. + * + * Computes identical line breaks for two fonts simultaneously by constructing a + * "unified" prepared-text object whose per-glyph widths are the worst-case maximum + * of font A and font B. This guarantees that both fonts wrap at exactly the same + * positions, making side-by-side or slider comparison visually coherent. + * + * **Two-level caching strategy** + * 1. Font-change cache (`#preparedA`, `#preparedB`, `#unifiedPrepared`): rebuilt only + * when `text`, `fontA`, or `fontB` changes. `prepareWithSegments` is expensive + * (canvas measurement), so this avoids re-measuring during slider interaction. + * 2. Layout cache (`#lastResult`): rebuilt when `width` or `lineHeight` changes but + * the fonts have not changed. Line-breaking is cheap relative to measurement, but + * still worth skipping on every render tick. + * + * **`as any` casts:** `PreparedTextWithSegments` exposes only the `segments` field in + * its public TypeScript type. The numeric arrays (`widths`, `breakableFitAdvances`, + * `lineEndFitAdvances`, `lineEndPaintAdvances`) are internal implementation details of + * pretext that are not part of the published type signature. The casts are required to + * access these fields; they are verified against the pretext source at + * `node_modules/@chenglou/pretext/src/layout.ts`. + */ export class CharacterComparisonEngine { #segmenter: Intl.Segmenter; @@ -46,8 +85,17 @@ export class CharacterComparisonEngine { } /** - * Unified layout for two fonts - * Ensures consistent line breaks by taking the worst-case width + * Lay out `text` using both fonts within `width` pixels. + * + * Line breaks are determined by the worst-case (maximum) glyph widths across + * both fonts, so both fonts always wrap at identical positions. + * + * @param text Raw text to lay out. + * @param fontA CSS font string for the first font: `"weight sizepx \"family\""`. + * @param fontB CSS font string for the second font: `"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 for both fonts. Empty `lines` when `text` is empty. */ layout( text: string, @@ -82,7 +130,9 @@ export class CharacterComparisonEngine { return { lines: [], totalHeight: 0 }; } - // 2. Layout using the unified widths + // 2. Layout using the unified widths. + // `PreparedTextWithSegments` only exposes `segments` in its public type; cast to `any` + // so pretext's layoutWithLines can read the internal numeric arrays at runtime. const { lines, height } = layoutWithLines(this.#unifiedPrepared as any, width, lineHeight); // 3. Map results back to both fonts @@ -94,6 +144,7 @@ export class CharacterComparisonEngine { const start = line.start; const end = line.end; + // Cast to `any`: accessing internal numeric arrays not in the public type signature. const intA = this.#preparedA as any; const intB = this.#preparedB as any; @@ -145,14 +196,24 @@ export class CharacterComparisonEngine { } /** - * Calculates character state without any DOM calls + * Calculates character proximity and direction relative to a slider position. + * + * Uses the most recent `layout()` result — must be called after `layout()`. + * No DOM calls are made; all geometry is derived from cached layout data. + * + * @param lineIndex Zero-based index of the line within the last layout result. + * @param charIndex Zero-based index of the character within that line's `chars` array. + * @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`. + * @param containerWidth Total container width in pixels, used to convert pixel offsets to %. + * @returns `proximity` in [0, 1] (1 = slider exactly over char center) and + * `isPast` (true when the slider has already passed the char center). */ getCharState( lineIndex: number, charIndex: number, - sliderPos: number, // 0-100 percentage + sliderPos: number, containerWidth: number, - ) { + ): { proximity: number; isPast: boolean } { if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) { return { proximity: 0, isPast: false }; } @@ -181,6 +242,7 @@ export class CharacterComparisonEngine { * Internal helper to merge two prepared texts into a "worst-case" unified version */ #createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments { + // Cast to `any`: accessing internal numeric arrays not in the public type signature. const intA = a as any; const intB = b as any; diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts index 47db20c..28c6c4c 100644 --- a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -36,7 +36,7 @@ describe('CharacterComparisonEngine', () => { expect(result.totalHeight).toBe(0); }); - it('uses worst-case (FontB) width to determine line breaks', () => { + it('uses worst-case width across both fonts to determine line breaks', () => { // 'AB CD' — two 2-char words separated by a space. // FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total. // FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '. diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts index 0aec801..048bf0f 100644 --- a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -24,7 +24,11 @@ export interface LayoutLine { }>; } +/** + * Aggregated output of a single-font layout pass. + */ export interface LayoutResult { + /** Per-line grapheme data. Empty when input text is empty. */ lines: LayoutLine[]; /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ totalHeight: number; @@ -61,6 +65,7 @@ export class TextLayoutEngine { */ #segmenter: Intl.Segmenter; + /** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */ constructor(locale?: string) { this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); }