From 5977e0a0dcb8ffabe0be2ed5b65bfcf5e308df38 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:14:28 +0300 Subject: [PATCH] fix: correct advances null-check in CharacterComparisonEngine and remove unused TextLayoutEngine dep --- .../CharacterComparisonEngine.svelte.ts | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts new file mode 100644 index 0000000..1d0dc65 --- /dev/null +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts @@ -0,0 +1,208 @@ +import { + type PreparedTextWithSegments, + layoutWithLines, + prepareWithSegments, +} from '@chenglou/pretext'; + +/** + * Result of dual-font comparison layout + */ +export interface ComparisonLine { + text: string; + width: number; // Max width between font A and B + chars: Array<{ + char: string; + xA: number; // X offset in font A + widthA: number; + xB: number; // X offset in font B + widthB: number; + }>; +} + +export interface ComparisonResult { + lines: ComparisonLine[]; + totalHeight: number; +} + +export class CharacterComparisonEngine { + #segmenter: Intl.Segmenter; + + // Cached prepared data + #preparedA: PreparedTextWithSegments | null = null; + #preparedB: PreparedTextWithSegments | null = null; + #unifiedPrepared: PreparedTextWithSegments | null = null; + + #lastText = ''; + #lastFontA = ''; + #lastFontB = ''; + + // Cached layout results + #lastWidth = -1; + #lastLineHeight = -1; + #lastResult: ComparisonResult | null = null; + + constructor(locale?: string) { + this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); + } + + /** + * Unified layout for two fonts + * Ensures consistent line breaks by taking the worst-case width + */ + layout( + text: string, + fontA: string, + fontB: string, + width: number, + lineHeight: number, + ): ComparisonResult { + if (!text) { + return { lines: [], totalHeight: 0 }; + } + + const isFontChange = text !== this.#lastText || fontA !== this.#lastFontA || fontB !== this.#lastFontB; + 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); + + this.#lastText = text; + this.#lastFontA = fontA; + this.#lastFontB = fontB; + } + + if (!this.#unifiedPrepared || !this.#preparedA || !this.#preparedB) { + return { lines: [], totalHeight: 0 }; + } + + // 2. Layout using the unified widths + 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; + + 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; + + // PERFORMANCE: Reuse segmenter results if possible, but for now just optimize the loop + 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]; + const wA = advA != null ? advA[gIdx]! : intA.widths[sIdx]!; + const wB = advB != null ? advB[gIdx]! : intB.widths[sIdx]!; + + 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 state without any DOM calls + */ + getCharState( + lineIndex: number, + charIndex: number, + sliderPos: number, // 0-100 percentage + containerWidth: number, + ) { + if (!this.#lastResult || !this.#lastResult.lines[lineIndex]) { + return { proximity: 0, isPast: false }; + } + + const line = this.#lastResult.lines[lineIndex]; + const char = line.chars[charIndex]; + + if (!char) return { proximity: 0, isPast: false }; + + // Center the comparison on the unified width + // In the UI, lines are centered. So we need to calculate the global X. + const lineXOffset = (containerWidth - line.width) / 2; + const charCenterX = lineXOffset + char.xA + (char.widthA / 2); + + const charGlobalPercent = (charCenterX / containerWidth) * 100; + + const distance = Math.abs(sliderPos - charGlobalPercent); + const range = 5; + const proximity = Math.max(0, 1 - distance / range); + const isPast = sliderPos > charGlobalPercent; + + return { proximity, isPast }; + } + + /** + * Internal helper to merge two prepared texts into a "worst-case" unified version + */ + #createUnifiedPrepared(a: PreparedTextWithSegments, b: PreparedTextWithSegments): PreparedTextWithSegments { + 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])); + unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) => + Math.max(w, intB.lineEndFitAdvances[i]) + ); + unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) => + Math.max(w, intB.lineEndPaintAdvances[i]) + ); + + unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => { + const advB = intB.breakableFitAdvances[i]; + if (!advA && !advB) return null; + if (!advA) return advB; + if (!advB) return advA; + + return advA.map((w: number, j: number) => Math.max(w, advB[j])); + }); + + return unified; + } +}