Files
frontend-svelte/src/entities/Font/domain/DualFontLayout/DualFontLayout.ts
T
Ilia Mashkov 7d66b0bc92 refactor(font): extract glyph-comparison logic into a domain segment
Move DualFontLayout and computeLineRenderModel from lib/ to a new
domain/ segment. This is the pure glyph-comparison algorithm — no
framework, no UI, no shared/model dependencies — so it belongs in
domain per the FSD+ ui -> model -> domain interior rule, shielded from
UI-kit and API churn.

Public API is unchanged: the slice index re-exports domain/, so
$entities/Font consumers (ComparisonView SliderArea, Line) are
unaffected.
2026-05-31 14:08:09 +03:00

279 lines
9.3 KiB
TypeScript

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;
/**
* Per-grapheme data computed during dual-font layout. Internal to the engine;
* consumed by computeLineRenderModel to derive the per-frame render model.
*/
export interface ComparisonChar {
/**
* Grapheme cluster (may be >1 code unit for emoji, combining marks).
*/
char: string;
/**
* X offset from line start in fontA, pixels.
*/
xA: number;
/**
* Advance width of this grapheme in fontA, pixels.
*/
widthA: number;
/**
* X offset from line start in fontB, pixels.
*/
xB: number;
/**
* Advance width of this grapheme in fontB, pixels.
*/
widthB: number;
}
/**
* A single laid-out line. `chars` carries the per-grapheme data needed by
* computeLineRenderModel. Consumers should not iterate it directly.
*/
export interface ComparisonLine {
/**
* Full text of this line as returned by pretext.
*/
text: string;
/**
* Rendered width in pixels — maximum across fontA and fontB.
*/
width: number;
/**
* Per-grapheme metadata for both fonts.
*/
chars: ComparisonChar[];
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/**
* Per-line grapheme data. Empty when input text is empty.
*/
lines: ComparisonLine[];
/**
* Total height in pixels.
*/
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.
*
* Relies on pretext's published structural fields on `PreparedTextWithSegments`
* (`widths`, `breakableFitAdvances`, `lineEndFitAdvances`, `lineEndPaintAdvances`)
* which are exposed via the `PreparedCore` intersection in `@chenglou/pretext@0.0.6`.
*
* **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: 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: 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 = 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 };
}
const { lines, height } = layoutWithLines(this.#unifiedPrepared, 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: PreparedTextWithSegments,
b: PreparedTextWithSegments,
spacingPx: number = 0,
): PreparedTextWithSegments {
const unified: PreparedTextWithSegments = { ...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;
}
}