fix: add missing JSDoc, return types, and as-any comments to layout engines

This commit is contained in:
Ilia Mashkov
2026-04-12 09:51:36 +03:00
parent 49822f8af7
commit 4b017a83bb
3 changed files with 78 additions and 11 deletions

View File

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

View File

@@ -36,7 +36,7 @@ describe('CharacterComparisonEngine', () => {
expect(result.totalHeight).toBe(0); expect(result.totalHeight).toBe(0);
}); });
it('uses worst-case (FontB) width to determine line breaks', () => { it('uses worst-case width across both fonts to determine line breaks', () => {
// 'AB CD' — two 2-char words separated by a space. // 'AB CD' — two 2-char words separated by a space.
// FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total. // FontA: 'AB'=20px, 'CD'=20px. Both fit in 25px? No: 'AB CD' = 50px total.
// FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '. // FontB: 'AB'=30px, 'CD'=30px. Width 35px forces wrap after 'AB '.

View File

@@ -24,7 +24,11 @@ export interface LayoutLine {
}>; }>;
} }
/**
* Aggregated output of a single-font layout pass.
*/
export interface LayoutResult { export interface LayoutResult {
/** Per-line grapheme data. Empty when input text is empty. */
lines: LayoutLine[]; lines: LayoutLine[];
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */ /** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
totalHeight: number; totalHeight: number;
@@ -61,6 +65,7 @@ export class TextLayoutEngine {
*/ */
#segmenter: Intl.Segmenter; #segmenter: Intl.Segmenter;
/** @param locale BCP 47 language tag passed to Intl.Segmenter. Defaults to the runtime locale. */
constructor(locale?: string) { constructor(locale?: string) {
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' }); this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
} }