refactor(font): port engine to DualFontLayout with typed pretext internals
This commit is contained in:
@@ -1,3 +1,14 @@
|
||||
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;
|
||||
|
||||
/**
|
||||
* Internal shape of pretext's PreparedTextWithSegments — only `segments` is in
|
||||
* pretext's public TS type; the numeric arrays exist at runtime but are not in
|
||||
@@ -26,6 +37,15 @@ interface PretextInternals {
|
||||
lineEndPaintAdvances: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts pretext's runtime shape. The public TS type exposes only `segments`;
|
||||
* the numeric arrays exist at runtime but are absent from the published signature.
|
||||
* Centralizing the cast keeps the engine body free of `as any`.
|
||||
*/
|
||||
function asPretextInternals(prepared: PreparedTextWithSegments): PretextInternals {
|
||||
return prepared as unknown as PretextInternals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-grapheme data computed during dual-font layout. Internal to the engine;
|
||||
* consumed by computeLineRenderModel to derive the per-frame render model.
|
||||
@@ -85,3 +105,213 @@ export interface ComparisonResult {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* 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: PretextInternals | null = null;
|
||||
#preparedB: PretextInternals | null = null;
|
||||
#unifiedPrepared: PretextInternals | 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 = asPretextInternals(prepareWithSegments(text, fontA));
|
||||
this.#preparedB = asPretextInternals(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 };
|
||||
}
|
||||
|
||||
// pretext's `layoutWithLines` is typed against its public surface; pass the
|
||||
// runtime-internal shape through with one boundary cast.
|
||||
const { lines, height } = layoutWithLines(
|
||||
this.#unifiedPrepared as unknown as PreparedTextWithSegments,
|
||||
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: PretextInternals,
|
||||
b: PretextInternals,
|
||||
spacingPx: number = 0,
|
||||
): PretextInternals {
|
||||
const unified: PretextInternals = { ...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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user