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-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Full text of this line as returned by pretext.
|
|
|
|
|
|
*/
|
2026-04-11 16:14:28 +03:00
|
|
|
|
text: string;
|
2026-04-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Rendered width of this line in pixels — maximum across font A and font B.
|
|
|
|
|
|
*/
|
2026-04-12 09:51:36 +03:00
|
|
|
|
width: number;
|
2026-04-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Individual character metadata for both fonts in this line
|
|
|
|
|
|
*/
|
2026-04-11 16:14:28 +03:00
|
|
|
|
chars: Array<{
|
2026-04-17 12:14:55 +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-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* X offset from the start of the line in font A, in pixels.
|
|
|
|
|
|
*/
|
2026-04-12 09:51:36 +03:00
|
|
|
|
xA: number;
|
2026-04-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* Advance width of this grapheme in font A, in pixels.
|
|
|
|
|
|
*/
|
2026-04-11 16:14:28 +03:00
|
|
|
|
widthA: number;
|
2026-04-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* X offset from the start of the line in font B, in pixels.
|
|
|
|
|
|
*/
|
2026-04-12 09:51:36 +03:00
|
|
|
|
xB: number;
|
2026-04-17 12:14:55 +03:00
|
|
|
|
/**
|
|
|
|
|
|
* 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-17 12:14:55 +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-17 12:14:55 +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 = '';
|
2026-04-20 10:52:28 +03:00
|
|
|
|
#lastSpacing = 0;
|
|
|
|
|
|
#lastSize = 0;
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
|
|
|
|
|
// Cached layout results
|
|
|
|
|
|
#lastWidth = -1;
|
|
|
|
|
|
#lastLineHeight = -1;
|
2026-04-20 10:52:28 +03:00
|
|
|
|
#lastResult = $state<ComparisonResult | null>(null);
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
|
|
|
|
|
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).
|
2026-04-20 10:52:28 +03:00
|
|
|
|
* @param spacing Letter spacing in em (from typography settings).
|
|
|
|
|
|
* @param size Current font size in pixels (used to convert spacing em to px).
|
2026-04-12 09:51:36 +03:00
|
|
|
|
* @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,
|
2026-04-20 10:52:28 +03:00
|
|
|
|
spacing: number = 0,
|
|
|
|
|
|
size: number = 16,
|
2026-04-11 16:14:28 +03:00
|
|
|
|
): ComparisonResult {
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
return { lines: [], totalHeight: 0 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-04-20 10:52:28 +03:00
|
|
|
|
const spacingPx = spacing * size;
|
|
|
|
|
|
|
|
|
|
|
|
const isFontChange = text !== this.#lastText
|
|
|
|
|
|
|| fontA !== this.#lastFontA
|
|
|
|
|
|
|| fontB !== this.#lastFontB
|
|
|
|
|
|
|| spacing !== this.#lastSpacing
|
|
|
|
|
|
|| size !== this.#lastSize;
|
|
|
|
|
|
|
2026-04-11 16:14:28 +03:00
|
|
|
|
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);
|
2026-04-20 10:52:28 +03:00
|
|
|
|
this.#unifiedPrepared = this.#createUnifiedPrepared(this.#preparedA, this.#preparedB, spacingPx);
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
|
|
|
|
|
this.#lastText = text;
|
|
|
|
|
|
this.#lastFontA = fontA;
|
|
|
|
|
|
this.#lastFontB = fontB;
|
2026-04-20 10:52:28 +03:00
|
|
|
|
this.#lastSpacing = spacing;
|
|
|
|
|
|
this.#lastSize = size;
|
2026-04-11 16:14:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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];
|
2026-04-17 13:05:36 +03:00
|
|
|
|
if (segmentText === undefined) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
|
|
|
|
|
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];
|
2026-04-20 10:52:28 +03:00
|
|
|
|
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;
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
|
|
|
|
|
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-20 10:52:28 +03:00
|
|
|
|
* Calculates character states for an entire line in a single sequential pass.
|
2026-04-12 09:51:36 +03:00
|
|
|
|
*
|
2026-04-20 10:52:28 +03:00
|
|
|
|
* 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.
|
2026-04-12 09:51:36 +03:00
|
|
|
|
*
|
2026-04-20 10:52:28 +03:00
|
|
|
|
* @param line A single laid-out line from the last layout result.
|
2026-04-12 09:51:36 +03:00
|
|
|
|
* @param sliderPos Current slider position as a percentage (0–100) of `containerWidth`.
|
2026-04-20 10:52:28 +03:00
|
|
|
|
* @param containerWidth Total container width in pixels.
|
|
|
|
|
|
* @returns Per-character `proximity` and `isPast` in the same order as `line.chars`.
|
2026-04-11 16:14:28 +03:00
|
|
|
|
*/
|
2026-04-20 10:52:28 +03:00
|
|
|
|
getLineCharStates(
|
|
|
|
|
|
line: ComparisonLine,
|
2026-04-12 09:51:36 +03:00
|
|
|
|
sliderPos: number,
|
2026-04-11 16:14:28 +03:00
|
|
|
|
containerWidth: number,
|
2026-04-20 10:52:28 +03:00
|
|
|
|
): Array<{ proximity: number; isPast: boolean }> {
|
|
|
|
|
|
if (!line) {
|
|
|
|
|
|
return [];
|
2026-04-17 13:05:36 +03:00
|
|
|
|
}
|
2026-04-20 10:52:28 +03:00
|
|
|
|
const chars = line.chars;
|
|
|
|
|
|
const n = chars.length;
|
|
|
|
|
|
const sliderX = (sliderPos / 100) * containerWidth;
|
2026-04-11 16:14:28 +03:00
|
|
|
|
const range = 5;
|
2026-04-20 10:52:28 +03:00
|
|
|
|
// 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);
|
2026-04-20 11:06:54 +03:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-04-20 10:52:28 +03:00
|
|
|
|
// 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);
|
2026-04-20 11:06:54 +03:00
|
|
|
|
for (let i = 0; i < n; i++) {
|
|
|
|
|
|
isPastArr[i] = sliderX > thresholds[i] ? 1 : 0;
|
|
|
|
|
|
}
|
2026-04-20 10:52:28 +03:00
|
|
|
|
// 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 };
|
|
|
|
|
|
});
|
2026-04-11 16:14:28 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Internal helper to merge two prepared texts into a "worst-case" unified version
|
|
|
|
|
|
*/
|
2026-04-20 10:52:28 +03:00
|
|
|
|
#createUnifiedPrepared(
|
|
|
|
|
|
a: PreparedTextWithSegments,
|
|
|
|
|
|
b: PreparedTextWithSegments,
|
|
|
|
|
|
spacingPx: number = 0,
|
|
|
|
|
|
): 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 };
|
|
|
|
|
|
|
2026-04-20 10:52:28 +03:00
|
|
|
|
unified.widths = intA.widths.map((w: number, i: number) => Math.max(w, intB.widths[i]) + spacingPx);
|
2026-04-11 16:14:28 +03:00
|
|
|
|
unified.lineEndFitAdvances = intA.lineEndFitAdvances.map((w: number, i: number) =>
|
2026-04-20 10:52:28 +03:00
|
|
|
|
Math.max(w, intB.lineEndFitAdvances[i]) + spacingPx
|
2026-04-11 16:14:28 +03:00
|
|
|
|
);
|
|
|
|
|
|
unified.lineEndPaintAdvances = intA.lineEndPaintAdvances.map((w: number, i: number) =>
|
2026-04-20 10:52:28 +03:00
|
|
|
|
Math.max(w, intB.lineEndPaintAdvances[i]) + spacingPx
|
2026-04-11 16:14:28 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
unified.breakableFitAdvances = intA.breakableFitAdvances.map((advA: number[] | null, i: number) => {
|
|
|
|
|
|
const advB = intB.breakableFitAdvances[i];
|
2026-04-17 13:05:36 +03:00
|
|
|
|
if (!advA && !advB) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!advA) {
|
2026-04-20 10:52:28 +03:00
|
|
|
|
return advB.map((w: number) => w + spacingPx);
|
2026-04-17 13:05:36 +03:00
|
|
|
|
}
|
|
|
|
|
|
if (!advB) {
|
2026-04-20 10:52:28 +03:00
|
|
|
|
return advA.map((w: number) => w + spacingPx);
|
2026-04-17 13:05:36 +03:00
|
|
|
|
}
|
2026-04-11 16:14:28 +03:00
|
|
|
|
|
2026-04-20 10:52:28 +03:00
|
|
|
|
return advA.map((w: number, j: number) => Math.max(w, advB[j]) + spacingPx);
|
2026-04-11 16:14:28 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return unified;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|