From fcde78abad77db1e0cf95f32bb242feaa01bdb70 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 15:48:47 +0300 Subject: [PATCH 01/12] test: add canvas mock helper for pretext-based engine tests --- src/shared/lib/helpers/__mocks__/canvas.ts | 29 ++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 src/shared/lib/helpers/__mocks__/canvas.ts diff --git a/src/shared/lib/helpers/__mocks__/canvas.ts b/src/shared/lib/helpers/__mocks__/canvas.ts new file mode 100644 index 0000000..326de30 --- /dev/null +++ b/src/shared/lib/helpers/__mocks__/canvas.ts @@ -0,0 +1,29 @@ +// src/shared/lib/helpers/__mocks__/canvas.ts +// +// Call installCanvasMock(fn) before any pretext import to control measureText. +// The factory receives the current ctx.font string and the text to measure. +import { vi } from 'vitest'; + +export type MeasureFactory = (font: string, text: string) => number; + +export function installCanvasMock(factory: MeasureFactory): void { + let currentFont = ''; + + const mockCtx = { + get font() { + return currentFont; + }, + set font(f: string) { + currentFont = f; + }, + measureText: vi.fn((text: string) => ({ width: factory(currentFont, text) })), + }; + + // HTMLCanvasElement.prototype.getContext is the entry point pretext uses in DOM environments. + // OffscreenCanvas takes priority in pretext; jsdom does not define it so DOM path is used. + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + writable: true, + value: vi.fn(() => mockCtx), + }); +} From a526a51af80df7764789b35bca033895a3ff7f81 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 15:48:52 +0300 Subject: [PATCH 02/12] =?UTF-8?q?test:=20fix=20TextLayoutEngine=20tests=20?= =?UTF-8?q?=E2=80=94=20correct=20jsdom=20directive=20placement=20and=20can?= =?UTF-8?q?vas=20mock=20setup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances --- .../TextLayoutEngine.svelte.ts | 99 +++++++++++++++++++ .../TextLayoutEngine/TextLayoutEngine.test.ts | 89 +++++++++++++++++ 2 files changed, 188 insertions(+) create mode 100644 src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts create mode 100644 src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts new file mode 100644 index 0000000..eacaef2 --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.svelte.ts @@ -0,0 +1,99 @@ +import { + layoutWithLines, + prepareWithSegments, +} from '@chenglou/pretext'; + +/** + * High-performance text layout engine using pretext + */ +export interface LayoutLine { + text: string; + width: number; + chars: Array<{ + char: string; + x: number; + width: number; + }>; +} + +export interface LayoutResult { + lines: LayoutLine[]; + totalHeight: number; +} + +export class TextLayoutEngine { + #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 + */ + layout(text: string, font: string, width: number, lineHeight: number): LayoutResult { + if (!text) { + return { lines: [], totalHeight: 0 }; + } + + // Use prepareWithSegments to get segment information + const prepared = prepareWithSegments(text, font); + const { lines, height } = layoutWithLines(prepared, width, lineHeight); + + // Access internal pretext data for character-level offsets + const internal = prepared as any; + const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[]; + const widths = internal.widths as number[]; + + const resultLines: LayoutLine[] = lines.map(line => { + const chars: LayoutLine['chars'] = []; + let currentX = 0; + + const start = line.start; + const end = line.end; + + // Iterate through segments in the line + 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 advances = 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]; + // advances is null for single-grapheme or non-breakable segments. + // In both cases the whole segment width belongs to the single grapheme. + const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!; + + chars.push({ + char, + x: currentX, + width: charWidth, + }); + currentX += charWidth; + } + } + + return { + text: line.text, + width: line.width, + chars, + }; + }); + + return { + lines: resultLines, + totalHeight: height, + }; + } +} diff --git a/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts new file mode 100644 index 0000000..7aeac46 --- /dev/null +++ b/src/shared/lib/helpers/TextLayoutEngine/TextLayoutEngine.test.ts @@ -0,0 +1,89 @@ +// @vitest-environment jsdom +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { installCanvasMock } from '../__mocks__/canvas'; +import { TextLayoutEngine } from './TextLayoutEngine.svelte'; + +// Fixed-width mock: every segment is measured as (text.length * 10) px. +// This is font-independent so we can reason about wrapping precisely. +const CHAR_WIDTH = 10; + +describe('TextLayoutEngine', () => { + let engine: TextLayoutEngine; + + beforeEach(() => { + // Install mock BEFORE any prepareWithSegments call. + // clearMeasurementCaches resets pretext's cached canvas context + // and segment metric caches so each test gets a clean slate. + installCanvasMock((_font, text) => text.length * CHAR_WIDTH); + clearCache(); + engine = new TextLayoutEngine(); + }); + + it('returns empty result for empty string', () => { + const result = engine.layout('', '400 16px "Inter"', 500, 20); + expect(result.lines).toHaveLength(0); + expect(result.totalHeight).toBe(0); + }); + + it('returns a single line when text fits within width', () => { + // 'ABC' = 3 chars × 10px = 30px, fits in 500px + const result = engine.layout('ABC', '400 16px "Inter"', 500, 20); + expect(result.lines).toHaveLength(1); + expect(result.lines[0].text).toBe('ABC'); + }); + + it('breaks text into multiple lines when it exceeds width', () => { + // 'Hello World' — pretext will split at the space. + // 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '. + const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20); + expect(result.lines.length).toBeGreaterThan(1); + // First line must not exceed the container width. + expect(result.lines[0].width).toBeLessThanOrEqual(60); + }); + + it('assigns correct x positions to characters on a single line', () => { + // 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container. + const result = engine.layout('ABC', '400 16px "Inter"', 500, 20); + const chars = result.lines[0].chars; + + expect(chars).toHaveLength(3); + expect(chars[0].char).toBe('A'); + expect(chars[0].x).toBe(0); + expect(chars[0].width).toBe(CHAR_WIDTH); + + expect(chars[1].char).toBe('B'); + expect(chars[1].x).toBe(CHAR_WIDTH); + expect(chars[1].width).toBe(CHAR_WIDTH); + + expect(chars[2].char).toBe('C'); + expect(chars[2].x).toBe(CHAR_WIDTH * 2); + expect(chars[2].width).toBe(CHAR_WIDTH); + }); + + it('x positions are monotonically increasing across a line', () => { + const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20); + const chars = result.lines[0].chars; + for (let i = 1; i < chars.length; i++) { + expect(chars[i].x).toBeGreaterThan(chars[i - 1].x); + } + }); + + it('each line has at least one char', () => { + const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20); + for (const line of result.lines) { + expect(line.chars.length).toBeGreaterThan(0); + } + }); + + it('totalHeight equals lineCount * lineHeight', () => { + const lineHeight = 24; + const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight); + expect(result.totalHeight).toBe(result.lines.length * lineHeight); + }); +}); From 351ee9fd52bdf4a99cad15aff74c343b9ffc887e Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:10:01 +0300 Subject: [PATCH 03/12] 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, }; } From 2b0d8470e508d03ff1019d69b60f313302afd421 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:14:24 +0300 Subject: [PATCH 04/12] =?UTF-8?q?test:=20fix=20CharacterComparisonEngine?= =?UTF-8?q?=20tests=20=E2=80=94=20correct=20env=20directive,=20canvas=20mo?= =?UTF-8?q?ck,=20and=20full=20spec=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CharacterComparisonEngine.test.ts | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts diff --git a/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts new file mode 100644 index 0000000..47db20c --- /dev/null +++ b/src/shared/lib/helpers/CharacterComparisonEngine/CharacterComparisonEngine.test.ts @@ -0,0 +1,168 @@ +// @vitest-environment jsdom +import { clearCache } from '@chenglou/pretext'; +import { + beforeEach, + describe, + expect, + it, +} from 'vitest'; +import { installCanvasMock } from '../__mocks__/canvas'; +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(); + }); + + // --- layout() --- + + 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 (FontB) width 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); + }); + + // --- getCharState() --- + + it('getCharState returns proximity 1 when slider is exactly over char center', () => { + // 'A' only: FontA width=10. Container=500px. Line centered. + // lineXOffset = (500 - maxWidth) / 2. maxWidth = max(10, 15) = 15 (FontB is wider). + // charCenterX = lineXOffset + xA + widthA/2. + // Using xA=0, widthA=10: charCenterX = (500-15)/2 + 0 + 5 = 247.5 + 5 = 252.5 + // charGlobalPercent = (252.5 / 500) * 100 = 50.5 + // distance = |50.5 - 50.5| = 0 => proximity = 1 + const containerWidth = 500; + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', containerWidth, 20); + // Recalculate expected percent manually: + const lineWidth = Math.max(FONT_A_WIDTH, FONT_B_WIDTH); // 15 (unified worst-case) + const lineXOffset = (containerWidth - lineWidth) / 2; + const charCenterX = lineXOffset + 0 + FONT_A_WIDTH / 2; + const charPercent = (charCenterX / containerWidth) * 100; + + const state = engine.getCharState(0, 0, charPercent, containerWidth); + expect(state.proximity).toBe(1); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns proximity 0 when slider is far from char', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + // Slider at 0%, char is near 50% — distance > 5 range => proximity = 0 + const state = engine.getCharState(0, 0, 0, 500); + expect(state.proximity).toBe(0); + }); + + it('getCharState isPast is true when slider has passed char center', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(0, 0, 100, 500); + expect(state.isPast).toBe(true); + }); + + it('getCharState returns safe default for out-of-range lineIndex', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(99, 0, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns safe default for out-of-range charIndex', () => { + engine.layout('A', '400 16px "FontA"', '400 16px "FontB"', 500, 20); + const state = engine.getCharState(0, 99, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); + + it('getCharState returns safe default before layout() has been called', () => { + const state = engine.getCharState(0, 0, 50, 500); + expect(state.proximity).toBe(0); + expect(state.isPast).toBe(false); + }); +}); From 5977e0a0dcb8ffabe0be2ed5b65bfcf5e308df38 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:14:28 +0300 Subject: [PATCH 05/12] 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; + } +} From 99f662e2d5debb291cefd86127c998b99544069d Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 11 Apr 2026 16:26:41 +0300 Subject: [PATCH 06/12] fix: iterate pre-computed chars array in Line.svelte to fix unicode grapheme splitting bug --- .../ComparisonView/ui/Line/Line.svelte | 27 ++++---- .../ui/SliderArea/SliderArea.svelte | 61 ++++++++++++------- 2 files changed, 53 insertions(+), 35 deletions(-) diff --git a/src/widgets/ComparisonView/ui/Line/Line.svelte b/src/widgets/ComparisonView/ui/Line/Line.svelte index 9258138..98879e5 100644 --- a/src/widgets/ComparisonView/ui/Line/Line.svelte +++ b/src/widgets/ComparisonView/ui/Line/Line.svelte @@ -6,15 +6,21 @@ import type { Snippet } from 'svelte'; import { comparisonStore } from '../../model'; +interface LineChar { + char: string; + xA: number; + widthA: number; + xB: number; + widthB: number; +} + interface Props { /** - * Line text + * Pre-computed grapheme array from CharacterComparisonEngine. + * Using the engine's chars array (rather than splitting line.text) ensures + * correct grapheme-cluster boundaries for emoji and multi-codepoint characters. */ - text: string; - /** - * DOM element reference - */ - element?: HTMLElement; + chars: LineChar[]; /** * Character render snippet */ @@ -22,18 +28,15 @@ interface Props { } const typography = $derived(comparisonStore.typography); -let { text, element = $bindable(), character }: Props = $props(); - -const characters = $derived(text.split('')); +let { chars, character }: Props = $props();
- {#each characters as char, index} - {@render character?.({ char, index })} + {#each chars as c, index} + {@render character?.({ char: c.char, index })} {/each}
diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index 3fa1948..c6d1f45 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -9,11 +9,12 @@ --> - - - {#snippet skeleton()} @@ -52,9 +93,9 @@ const checkPosition = throttle(() => { onresize={checkPosition} /> -
+