import { type PreparedTextWithSegments, layoutWithLines, prepareWithSegments, } from '@chenglou/pretext'; /** * 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; /** * Rendered width of this line in pixels — maximum across font A and font B. */ width: number; /** * Individual character metadata for both fonts in this line */ 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 font A, in pixels. */ xA: number; /** * Advance width of this grapheme in font A, in pixels. */ widthA: number; /** * 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; // Cached prepared data #preparedA: PreparedTextWithSegments | null = null; #preparedB: PreparedTextWithSegments | null = null; #unifiedPrepared: PreparedTextWithSegments | null = null; #lastText = ''; #lastFontA = ''; #lastFontB = ''; #lastSpacing = 0; #lastSize = 0; // Cached layout results #lastWidth = -1; #lastLineHeight = -1; #lastResult = $state(null); constructor(locale?: string) { this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); } /** * 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). * @param spacing Letter spacing in em (from typography settings). * @param size Current font size in pixels (used to convert spacing em to px). * @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty. */ layout( text: string, fontA: string, fontB: string, width: number, lineHeight: number, spacing: number = 0, size: number = 16, ): ComparisonResult { if (!text) { return { lines: [], totalHeight: 0 }; } const spacingPx = spacing * size; const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB || spacing !== this.#lastSpacing || size !== this.#lastSize; const isLayoutChange = width !== this.#lastWidth || lineHeight !== this.#lastLineHeight; if (!isFontChange && !isLayoutChange && this.#lastResult) { return this.#lastResult; } // 1. Prepare (or use cache) if (isFontChange) { this.#preparedA = prepareWithSegments(text, fontA); this.#preparedB = prepareWithSegments(text, fontB); this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx); this.#lastText = text; this.#lastFontA = fontA; this.#lastFontB = fontB; this.#lastSpacing = spacing; this.#lastSize = size; } if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) { return { lines: [], totalHeight: 0 }; } // 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 const resultLines: ComparisonLine[] = lines.map(line => { const chars: ComparisonLine['chars'] = []; let currentXA = 0; let currentXB = 0; 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; for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { const segmentText = this.#preparedA!.segments[sIdx]; if (segmentText === undefined) { continue; } const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); const advA = intA.breakableFitAdvances[sIdx]; const advB = intB.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]; let wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!; let wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!; // Apply letter spacing (tracking) to the width of each character wA += spacingPx; wB += spacingPx; chars.push({ char, xA: currentXA, widthA: wA, xB: currentXB, widthB: wB, }); currentXA += wA; currentXB += wB; } } return { text: line.text, width: line.width, chars, }; }); this.#lastWidth = width; this.#lastLineHeight = lineHeight; this.#lastResult = { lines: resultLines, totalHeight: height, }; return this.#lastResult; } /** * Calculates character states for an entire line in a single sequential pass. * * Walks characters left-to-right, accumulating the running x position using * each character's actual rendered width: `widthB` for already-morphed characters * (isPast=true) and `widthA` for upcoming ones. This ensures thresholds stay * aligned with the visual DOM layout even when the two fonts have different widths. * * @param line A single laid-out line from the last layout result. * @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`. * @param containerWidth Total container width in pixels. * @returns Per-character `proximity` and `isPast` in the same order as `line.chars`. */ getLineCharStates( line: ComparisonLine, sliderPos: number, containerWidth: number, ): Array<{ proximity: number; isPast: boolean }> { if (!line) { return []; } const chars = line.chars; const n = chars.length; const sliderX = (sliderPos / 100) * containerWidth; const range = 5; // Prefix sums of widthA (left chars will be past → use widthA). // Suffix sums of widthB (right chars will not be past → use widthB). // This lets us compute, for each char i, what the total line width and // char center would be at the exact moment the slider crosses that char: // left side (0..i-1) already past → font A widths // right side (i+1..n-1) not yet past → font B widths const prefA = new Float64Array(n + 1); const sufB = new Float64Array(n + 1); for (let i = 0; i < n; i++) { prefA[i + 1] = prefA[i] + chars[i].widthA; } for (let i = n - 1; i >= 0; i--) { sufB[i] = sufB[i + 1] + chars[i].widthB; } // Per-char threshold: slider x at which this char should toggle isPast. const thresholds = new Float64Array(n); for (let i = 0; i < n; i++) { const totalWidth = prefA[i] + chars[i].widthA + sufB[i + 1]; const xOffset = (containerWidth - totalWidth) / 2; thresholds[i] = xOffset + prefA[i] + chars[i].widthA / 2; } // Determine isPast for each char at the current slider position. const isPastArr = new Uint8Array(n); for (let i = 0; i < n; i++) { isPastArr[i] = sliderX > thresholds[i] ? 1 : 0; } // Compute visual positions based on actual rendered widths (font A if past, B if not). const totalRendered = chars.reduce((s, c, i) => s + (isPastArr[i] ? c.widthA : c.widthB), 0); const xOffset = (containerWidth - totalRendered) / 2; let currentX = xOffset; return chars.map((char, i) => { const isPast = isPastArr[i] === 1; const charWidth = isPast ? char.widthA : char.widthB; const visualCenter = currentX + charWidth / 2; const charGlobalPercent = (visualCenter / containerWidth) * 100; const distance = Math.abs(sliderPos - charGlobalPercent); const proximity = Math.max(0, 1 - distance / range); currentX += charWidth; return { proximity, isPast }; }); } /** * Internal helper to merge two prepared texts into a "worst-case" unified version */ #createUnifiedPrepared( a: PreparedTextWithSegments, b: PreparedTextWithSegments, spacingPx: number = 0, ): PreparedTextWithSegments { // Cast to `any`: accessing internal numeric arrays not in the public type signature. const intA = a as any; const intB = b as any; const unified = { ...intA }; unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx); unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) => Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx ); unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) => Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx ); unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => { const advB = intB.breakableFitAdvances[i]; if (!advA && !advB) { return null; } if (!advA) { return advB.map((w: number) => w + spacingPx); } if (!advB) { return advA.map((w: number) => w + spacingPx); } return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx); }); return unified; } }