docs: add inline documentation to TextLayoutEngine
This commit is contained in:
@@ -4,45 +4,90 @@ import {
|
|||||||
} from '@chenglou/pretext';
|
} from '@chenglou/pretext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* High-performance text layout engine using pretext
|
* A single laid-out line of text, with per-grapheme x offsets and widths.
|
||||||
|
*
|
||||||
|
* `chars` is indexed by grapheme cluster (not UTF-16 code unit), so emoji
|
||||||
|
* sequences and combining characters each produce exactly one entry.
|
||||||
*/
|
*/
|
||||||
export interface LayoutLine {
|
export interface LayoutLine {
|
||||||
|
/** Full text of this line as returned by pretext. */
|
||||||
text: string;
|
text: string;
|
||||||
|
/** Rendered width of this line in pixels. */
|
||||||
width: number;
|
width: number;
|
||||||
chars: Array<{
|
chars: Array<{
|
||||||
|
/** The grapheme cluster string (may be >1 code unit for emoji, etc.). */
|
||||||
char: string;
|
char: string;
|
||||||
|
/** X offset from the start of the line, in pixels. */
|
||||||
x: number;
|
x: number;
|
||||||
|
/** Advance width of this grapheme, in pixels. */
|
||||||
width: number;
|
width: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LayoutResult {
|
export interface LayoutResult {
|
||||||
lines: LayoutLine[];
|
lines: LayoutLine[];
|
||||||
|
/** Total height in pixels. Equals `lines.length * lineHeight` (pretext guarantee). */
|
||||||
totalHeight: number;
|
totalHeight: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single-font text layout engine backed by `@chenglou/pretext`.
|
||||||
|
*
|
||||||
|
* Replaces the canvas-DOM hybrid `createCharacterComparison` for cases where
|
||||||
|
* only one font is needed. For dual-font comparison use `CharacterComparisonEngine`.
|
||||||
|
*
|
||||||
|
* **Usage**
|
||||||
|
* ```ts
|
||||||
|
* const engine = new TextLayoutEngine();
|
||||||
|
* const result = engine.layout('Hello World', '400 16px "Inter"', 320, 24);
|
||||||
|
* // result.lines[0].chars → [{ char: 'H', x: 0, width: 9 }, ...]
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* **Font string format:** `"${weight} ${size}px \"${family}\""` — e.g. `'400 16px "Inter"'`.
|
||||||
|
* This matches what SliderArea constructs from `typography.weight` and `typography.renderedSize`.
|
||||||
|
*
|
||||||
|
* **Canvas requirement:** pretext calls `document.createElement('canvas').getContext('2d')` on
|
||||||
|
* first use and caches the context for the process lifetime. Tests must install a canvas mock
|
||||||
|
* (see `__mocks__/canvas.ts`) before the first `layout()` call.
|
||||||
|
*/
|
||||||
export class TextLayoutEngine {
|
export class TextLayoutEngine {
|
||||||
|
/**
|
||||||
|
* Grapheme segmenter used to split segment text into individual clusters.
|
||||||
|
*
|
||||||
|
* Pretext maintains its own internal segmenter for line-breaking decisions.
|
||||||
|
* We keep a separate one here so we can iterate graphemes in `layout()`
|
||||||
|
* without depending on pretext internals — the two segmenters produce
|
||||||
|
* identical boundaries because both use `{ granularity: 'grapheme' }`.
|
||||||
|
*/
|
||||||
#segmenter: Intl.Segmenter;
|
#segmenter: Intl.Segmenter;
|
||||||
|
|
||||||
constructor(locale?: string) {
|
constructor(locale?: string) {
|
||||||
// Use Intl.Segmenter for grapheme-level segmentation
|
|
||||||
// Pretext uses this internally, so we align with it.
|
|
||||||
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
this.#segmenter = new Intl.Segmenter(locale, { granularity: 'grapheme' });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Measure and layout text within a given width
|
* Lay out `text` in the given `font` within `width` pixels.
|
||||||
|
*
|
||||||
|
* @param text Raw text to lay out.
|
||||||
|
* @param font CSS font string: `"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. Empty `lines` when `text` is empty.
|
||||||
*/
|
*/
|
||||||
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
|
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return { lines: [], totalHeight: 0 };
|
return { lines: [], totalHeight: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use prepareWithSegments to get segment information
|
// prepareWithSegments measures the text and builds the segment data structure
|
||||||
|
// (widths, breakableFitAdvances, etc.) that the line-walker consumes.
|
||||||
const prepared = prepareWithSegments(text, font);
|
const prepared = prepareWithSegments(text, font);
|
||||||
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
|
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
|
||||||
|
|
||||||
// Access internal pretext data for character-level offsets
|
// `PreparedTextWithSegments` has these fields in its public type definition
|
||||||
|
// but the TypeScript signature only exposes `segments`. We cast to `any` to
|
||||||
|
// access the parallel numeric arrays — they are documented in the plan and
|
||||||
|
// verified against the pretext source at node_modules/@chenglou/pretext/src/layout.ts.
|
||||||
const internal = prepared as any;
|
const internal = prepared as any;
|
||||||
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
|
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
|
||||||
const widths = internal.widths as number[];
|
const widths = internal.widths as number[];
|
||||||
@@ -54,25 +99,29 @@ export class TextLayoutEngine {
|
|||||||
const start = line.start;
|
const start = line.start;
|
||||||
const end = line.end;
|
const end = line.end;
|
||||||
|
|
||||||
// Iterate through segments in the line
|
// Walk every segment that falls within this line's [start, end] cursors.
|
||||||
|
// Both cursors are grapheme-level: start is inclusive, end is exclusive.
|
||||||
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
|
||||||
const segmentText = prepared.segments[sIdx];
|
const segmentText = prepared.segments[sIdx];
|
||||||
if (segmentText === undefined) continue;
|
if (segmentText === undefined) continue;
|
||||||
|
|
||||||
// Get graphemes for this segment
|
const graphemes = Array.from(this.#segmenter.segment(segmentText), s => s.segment);
|
||||||
const segments = this.#segmenter.segment(segmentText);
|
|
||||||
const graphemes = Array.from(segments, s => s.segment);
|
|
||||||
|
|
||||||
// Get widths/advances for graphemes in this segment
|
|
||||||
const advances = breakableFitAdvances[sIdx];
|
const advances = breakableFitAdvances[sIdx];
|
||||||
|
|
||||||
|
// For the first and last segments of the line the cursor may point
|
||||||
|
// into the middle of the segment — respect those boundaries.
|
||||||
|
// All intermediate segments are walked in full (gStart=0, gEnd=length).
|
||||||
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
const gStart = sIdx === start.segmentIndex ? start.graphemeIndex : 0;
|
||||||
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
const gEnd = sIdx === end.segmentIndex ? end.graphemeIndex : graphemes.length;
|
||||||
|
|
||||||
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
for (let gIdx = gStart; gIdx < gEnd; gIdx++) {
|
||||||
const char = graphemes[gIdx];
|
const char = graphemes[gIdx];
|
||||||
// advances is null for single-grapheme or non-breakable segments.
|
|
||||||
// In both cases the whole segment width belongs to the single grapheme.
|
// `breakableFitAdvances[sIdx]` is an array of per-grapheme advance
|
||||||
|
// widths when the segment has >1 grapheme (multi-character words).
|
||||||
|
// It is `null` for single-grapheme segments (spaces, punctuation,
|
||||||
|
// emoji, etc.) — in that case the entire segment width is attributed
|
||||||
|
// to this single grapheme.
|
||||||
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
|
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
|
||||||
|
|
||||||
chars.push({
|
chars.push({
|
||||||
@@ -93,6 +142,7 @@ export class TextLayoutEngine {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
lines: resultLines,
|
lines: resultLines,
|
||||||
|
// pretext guarantees height === lineCount * lineHeight (see layout.ts source).
|
||||||
totalHeight: height,
|
totalHeight: height,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user