2026-04-11 16:14:28 +03:00
|
|
|
|
import {
|
|
|
|
|
|
type PreparedTextWithSegments,
|
|
|
|
|
|
layoutWithLines,
|
|
|
|
|
|
prepareWithSegments,
|
|
|
|
|
|
} from '@chenglou/pretext';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-12 09:51:36 +03:00
|
|
|
|
* 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.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
*/
|
|
|
|
|
|
export interface ComparisonLine {
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** Full text of this line as returned by pretext. */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
text: string;
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** Rendered width of this line in pixels — maximum across font A and font B. */
|
|
|
|
|
|
width: number;
|
2026-04-11 16:14:28 +03:00
|
|
|
|
chars: Array<{
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
char: string;
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** 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. */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
widthA: number;
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** 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. */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
widthB: number;
|
|
|
|
|
|
}>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Aggregated output of a dual-font layout pass.
|
|
|
|
|
|
*/
|
2026-04-11 16:14:28 +03:00
|
|
|
|
export interface ComparisonResult {
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
lines: ComparisonLine[];
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
2026-04-11 16:14:28 +03:00
|
|
|
|
totalHeight: number;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:51:36 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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`.
|
|
|
|
|
|
*/
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-12 09:51:36 +03:00
|
|
|
|
* 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).
|
|
|
|
|
|
* @returns Per-line grapheme data for both fonts. Empty `lines` when `text` is empty.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
*/
|
|
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-12 09:51:36 +03:00
|
|
|
|
// 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.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-04-12 09:51:36 +03:00
|
|
|
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-04-12 09:51:36 +03:00
|
|
|
|
* Calculates character proximity and direction relative to a slider position.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Uses the most recent `layout()` result — must be called after `layout()`.
|
|
|
|
|
|
* No DOM calls are made; all geometry is derived from cached layout data.
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param lineIndex Zero-based index of the line within the last layout result.
|
|
|
|
|
|
* @param charIndex Zero-based index of the character within that line's `chars` array.
|
|
|
|
|
|
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
|
|
|
|
|
* @param containerWidth Total container width in pixels, used to convert pixel offsets to %.
|
|
|
|
|
|
* @returns `proximity` in [0, 1] (1 = slider exactly over char center) and
|
|
|
|
|
|
* `isPast` (true when the slider has already passed the char center).
|
2026-04-11 16:14:28 +03:00
|
|
|
|
*/
|
|
|
|
|
|
getCharState(
|
|
|
|
|
|
lineIndex: number,
|
|
|
|
|
|
charIndex: number,
|
2026-04-12 09:51:36 +03:00
|
|
|
|
sliderPos: number,
|
2026-04-11 16:14:28 +03:00
|
|
|
|
containerWidth: number,
|
2026-04-12 09:51:36 +03:00
|
|
|
|
): { proximity: number; isPast: boolean } {
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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 {
|
2026-04-12 09:51:36 +03:00
|
|
|
|
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|