@@ -5,25 +5,64 @@ import {
} from '@chenglou/pretext' ;
/**
* Result of dual-font comparison layout
* 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.
*/
export interface ComparisonLine {
/** Full text of this line as returned by pretext. */
text : string ;
width : number ; // Max width between font A and B
/** Rendered width of this line in pixels — maximum across font A and font B. */
width : number ;
chars : Array < {
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
char : string ;
xA : number ; // X offset in font A
/** 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. */
widthA : number ;
xB : number ; // X offset in font B
/** 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. */
widthB : number ;
} > ;
}
/**
* Aggregated output of a dual-font layout pass.
*/
export interface ComparisonResult {
/** Per-line grapheme data for both fonts. Empty when input text is empty. */
lines : ComparisonLine [ ] ;
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
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.
*
* **`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`.
*/
export class CharacterComparisonEngine {
# segmenter : Intl.Segmenter ;
@@ -46,8 +85,17 @@ export class CharacterComparisonEngine {
}
/**
* Unified l ayout for two fonts
* Ensures consistent line breaks by taking the worst-case width
* L ay 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.
*/
layout (
text : string ,
@@ -82,7 +130,9 @@ export class CharacterComparisonEngine {
return { lines : [ ] , totalHeight : 0 } ;
}
// 2. Layout using the unified widths
// 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.
const { lines , height } = layoutWithLines ( this . # unifiedPrepared as any , width , lineHeight ) ;
// 3. Map results back to both fonts
@@ -94,6 +144,7 @@ export class CharacterComparisonEngine {
const start = line . start ;
const end = line . end ;
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = this . # preparedA as any ;
const intB = this . # preparedB as any ;
@@ -145,14 +196,24 @@ export class CharacterComparisonEngine {
}
/**
* Calculates character state without any DOM calls
* 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).
*/
getCharState (
lineIndex : number ,
charIndex : number ,
sliderPos : number , // 0-100 percentage
sliderPos : number ,
containerWidth : number ,
) {
) : { proximity : number ; isPast : boolean } {
if ( ! this . # lastResult || ! this . # lastResult . lines [ lineIndex ] ) {
return { proximity : 0 , isPast : false } ;
}
@@ -181,6 +242,7 @@ export class CharacterComparisonEngine {
* Internal helper to merge two prepared texts into a "worst-case" unified version
*/
# createUnifiedPrepared ( a : PreparedTextWithSegments , b : PreparedTextWithSegments ) : PreparedTextWithSegments {
// Cast to `any`: accessing internal numeric arrays not in the public type signature.
const intA = a as any ;
const intB = b as any ;