diff --git a/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts b/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts deleted file mode 100644 index 232b621..0000000 --- a/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.svelte.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { - type PreparedTextWithSegments, - layoutWithLines, - prepareWithSegments, -} from '@chenglou/pretext'; - -/** - * Width of the character morph "halo" around the slider thumb, in percent - * of container width. Characters within this window get partial blending - * instead of a hard A→B flip. - */ -const CHAR_PROXIMITY_RANGE_PCT = 5; - -/** - * Default render size in px when callers omit the `size` arg on `layout()`. - */ -const DEFAULT_RENDER_SIZE_PX = 16; - -/** - * 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 = DEFAULT_RENDER_SIZE_PX, - ): 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 = CHAR_PROXIMITY_RANGE_PCT; - // 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; - } -} diff --git a/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.test.ts deleted file mode 100644 index 43ed97d..0000000 --- a/src/entities/Font/lib/CharacterComparisonEngine/CharacterComparisonEngine.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -// @vitest-environment jsdom -import { installCanvasMock } from '$shared/lib/helpers/__mocks__/canvas'; -import { clearCache } from '@chenglou/pretext'; -import { - beforeEach, - describe, - expect, - it, -} from 'vitest'; -import { CharacterComparisonEngine } from './CharacterComparisonEngine.svelte'; - -// FontA: 10px per character. FontB: 15px per character. -// The mock dispatches on whether the font string contains 'FontA' or 'FontB'. -const FONT_A_WIDTH = 10; -const FONT_B_WIDTH = 15; - -function fontWidthFactory(font: string, text: string): number { - const perChar = font.includes('FontA') ? FONT_A_WIDTH : FONT_B_WIDTH; - return text.length * perChar; -} - -describe('CharacterComparisonEngine', () => { - let engine: CharacterComparisonEngine; - - beforeEach(() => { - installCanvasMock(fontWidthFactory); - clearCache(); - engine = new CharacterComparisonEngine(); - }); - - it('returns empty result for empty string', () => { - const result = engine.layout('', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - expect(result.lines).toHaveLength(0); - expect(result.totalHeight).toBe(0); - }); - - 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 '. - // Unified must use FontB widths — so it must wrap at the same place FontB wraps. - const result = engine.layout('AB CD', '400 16px "FontA"', '400 16px "FontB"', 35, 20); - expect(result.lines.length).toBeGreaterThan(1); - // First line text must not include both words. - expect(result.lines[0].text).not.toContain('CD'); - }); - - it('provides xA and xB offsets for both fonts on a single line', () => { - // 'ABC' fits in 500px for both fonts. - // FontA: A@0(w=10), B@10(w=10), C@20(w=10) - // FontB: A@0(w=15), B@15(w=15), C@30(w=15) - const result = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const chars = result.lines[0].chars; - - expect(chars).toHaveLength(3); - - expect(chars[0].xA).toBe(0); - expect(chars[0].widthA).toBe(FONT_A_WIDTH); - expect(chars[0].xB).toBe(0); - expect(chars[0].widthB).toBe(FONT_B_WIDTH); - - expect(chars[1].xA).toBe(FONT_A_WIDTH); // 10 - expect(chars[1].widthA).toBe(FONT_A_WIDTH); - expect(chars[1].xB).toBe(FONT_B_WIDTH); // 15 - expect(chars[1].widthB).toBe(FONT_B_WIDTH); - - expect(chars[2].xA).toBe(FONT_A_WIDTH * 2); // 20 - expect(chars[2].xB).toBe(FONT_B_WIDTH * 2); // 30 - }); - - it('xA positions are monotonically increasing', () => { - const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const chars = result.lines[0].chars; - for (let i = 1; i < chars.length; i++) { - expect(chars[i].xA).toBeGreaterThan(chars[i - 1].xA); - } - }); - - it('xB positions are monotonically increasing', () => { - const result = engine.layout('ABCDE', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const chars = result.lines[0].chars; - for (let i = 1; i < chars.length; i++) { - expect(chars[i].xB).toBeGreaterThan(chars[i - 1].xB); - } - }); - - it('returns cached result when called again with same arguments', () => { - const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const r2 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - expect(r2).toBe(r1); // strict reference equality — same object - }); - - it('re-computes when text changes', () => { - const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const r2 = engine.layout('DEF', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - expect(r2).not.toBe(r1); - expect(r2.lines[0].text).not.toBe(r1.lines[0].text); - }); - - it('re-computes when width changes', () => { - const r1 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const r2 = engine.layout('Hello World', '400 16px "FontA"', '400 16px "FontB"', 60, 20); - expect(r2).not.toBe(r1); - }); - - it('re-computes when fontA changes', () => { - const r1 = engine.layout('ABC', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const r2 = engine.layout('ABC', '400 24px "FontA"', '400 16px "FontB"', 500, 20); - expect(r2).not.toBe(r1); - }); - - it('getLineCharStates returns proximity 1 when slider is exactly over char center', () => { - const containerWidth = 500; - const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); - // Single char: no neighbors → totalWidth = widthA, threshold = containerWidth/2. - // When isPast=false, visual center = (containerWidth - widthB)/2 + widthB/2 = containerWidth/2. - // So proximity=1 at exactly 50%. - const charPercent = 50; - - const states = engine.getLineCharStates(result.lines[0], charPercent, containerWidth); - expect(states[0]?.proximity).toBe(1); - expect(states[0]?.isPast).toBe(false); - }); - - it('getLineCharStates returns proximity 0 when slider is far from char', () => { - const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const states = engine.getLineCharStates(result.lines[0], 0, 500); - expect(states[0]?.proximity).toBe(0); - }); - - it('getLineCharStates isPast is true when slider has passed char center', () => { - const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const states = engine.getLineCharStates(result.lines[0], 100, 500); - expect(states[0]?.isPast).toBe(true); - }); - - it('getLineCharStates returns empty array for out-of-range lineIndex', () => { - const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - // Passing an undefined object because the index doesn't exist. - const states = engine.getLineCharStates(result.lines[99], 50, 500); - expect(states).toEqual([]); - }); - - it('getLineCharStates returns empty array before layout() has been called', () => { - // Passing an undefined object because layout() hasn't been called. - const states = engine.getLineCharStates(undefined as any, 50, 500); - expect(states).toEqual([]); - }); - - it('getLineCharStates returns safe defaults for all chars', () => { - const result = engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); - const states = engine.getLineCharStates(result.lines[0], 50, 500); - expect(states.length).toBeGreaterThan(0); - for (const s of states) { - expect(s.proximity).toBeGreaterThanOrEqual(0); - expect(s.proximity).toBeLessThanOrEqual(1); - expect(typeof s.isPast).toBe('boolean'); - } - }); -}); diff --git a/src/entities/Font/lib/index.ts b/src/entities/Font/lib/index.ts index a305d06..017b86e 100644 --- a/src/entities/Font/lib/index.ts +++ b/src/entities/Font/lib/index.ts @@ -1,5 +1,3 @@ -export { CharacterComparisonEngine } from './CharacterComparisonEngine/CharacterComparisonEngine.svelte'; - export * from './dualFontLayout'; export { getFontUrl } from './getFontUrl/getFontUrl';