From 1fa099bef5ef33e83d506b04c33df134458604c5 Mon Sep 17 00:00:00 2001 From: Ilia Mashkov Date: Sat, 30 May 2026 22:12:00 +0300 Subject: [PATCH] refactor(font): port engine to DualFontLayout with typed pretext internals --- .../DualFontLayout/DualFontLayout.ts | 230 ++++++++++++++++++ 1 file changed, 230 insertions(+) diff --git a/src/entities/Font/lib/dualFontLayout/DualFontLayout/DualFontLayout.ts b/src/entities/Font/lib/dualFontLayout/DualFontLayout/DualFontLayout.ts index 3451ec5..29e43e9 100644 --- a/src/entities/Font/lib/dualFontLayout/DualFontLayout/DualFontLayout.ts +++ b/src/entities/Font/lib/dualFontLayout/DualFontLayout/DualFontLayout.ts @@ -1,3 +1,14 @@ +import { + type PreparedTextWithSegments, + layoutWithLines, + prepareWithSegments, +} from '@chenglou/pretext'; + +/** + * Default render size in px when callers omit the `size` arg on `layout()`. + */ +const DEFAULT_RENDER_SIZE_PX = 16; + /** * Internal shape of pretext's PreparedTextWithSegments — only `segments` is in * pretext's public TS type; the numeric arrays exist at runtime but are not in @@ -26,6 +37,15 @@ interface PretextInternals { lineEndPaintAdvances: number[]; } +/** + * Asserts pretext's runtime shape. The public TS type exposes only `segments`; + * the numeric arrays exist at runtime but are absent from the published signature. + * Centralizing the cast keeps the engine body free of `as any`. + */ +function asPretextInternals(prepared: PreparedTextWithSegments): PretextInternals { + return prepared as unknown as PretextInternals; +} + /** * Per-grapheme data computed during dual-font layout. Internal to the engine; * consumed by computeLineRenderModel to derive the per-frame render model. @@ -85,3 +105,213 @@ export interface ComparisonResult { */ 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. + * + * Per-frame slider state derivation lives in `computeLineRenderModel`, not on the + * class. This class is pure layout + caching; it holds no reactive state. + */ +export class DualFontLayout { + #segmenter: Intl.Segmenter; + + // Cached prepared data + #preparedA: PretextInternals | null = null; + #preparedB: PretextInternals | null = null; + #unifiedPrepared: PretextInternals | null = null; + + #lastText = ''; + #lastFontA = ''; + #lastFontB = ''; + #lastSpacing = 0; + #lastSize = 0; + + // Cached layout results + #lastWidth = -1; + #lastLineHeight = -1; + #lastResult: ComparisonResult | null = 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 = asPretextInternals(prepareWithSegments(text, fontA)); + this.#preparedB = asPretextInternals(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 }; + } + + // pretext's `layoutWithLines` is typed against its public surface; pass the + // runtime-internal shape through with one boundary cast. + const { lines, height } = layoutWithLines( + this.#unifiedPrepared as unknown as PreparedTextWithSegments, + width, + lineHeight, + ); + + // 3. Map results back to both fonts + const preparedA = this.#preparedA; + const preparedB = this.#preparedB; + const resultLines: ComparisonLine[] = lines.map(line => { + const chars: ComparisonChar[] = []; + let currentXA = 0; + let currentXB = 0; + + const start = line.start; + const end = line.end; + + for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) { + const segmentText = preparedA.segments[sIdx]; + if (segmentText === undefined) { + continue; + } + + const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment); + + const advA = preparedA.breakableFitAdvances[sIdx]; + const advB = preparedB.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]! : preparedA.widths[sIdx]!; + let wB = advB != null ? advB[gIdx]! : preparedB.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; + } + + /** + * Merge two prepared texts into a worst-case unified version so both fonts + * wrap at identical positions. Per-segment widths are the elementwise max + * across both fonts, with `spacingPx` added to model letter-spacing. + */ + #createUnifiedPrepared( + a: PretextInternals, + b: PretextInternals, + spacingPx: number = 0, + ): PretextInternals { + const unified: PretextInternals = { ...a }; + + unified.widths = a.widths.map((w, i) => Math.max(w, b.widths[i]) + spacingPx); + unified.lineEndFitAdvances = a.lineEndFitAdvances.map((w, i) => + Math.max(w, b.lineEndFitAdvances[i]) + spacingPx + ); + unified.lineEndPaintAdvances = a.lineEndPaintAdvances.map((w, i) => + Math.max(w, b.lineEndPaintAdvances[i]) + spacingPx + ); + + unified.breakableFitAdvances = a.breakableFitAdvances.map((advA, i) => { + const advB = b.breakableFitAdvances[i]; + if (!advA && !advB) { + return null; + } + if (!advA) { + return advB!.map(w => w + spacingPx); + } + if (!advB) { + return advA.map(w => w + spacingPx); + } + return advA.map((w, j) => Math.max(w, advB[j]) + spacingPx); + }); + + return unified; + } +}