test: fix TextLayoutEngine tests — correct jsdom directive placement and canvas mock setup

fix: correct grapheme-width fallback in TextLayoutEngine for null breakableFitAdvances
This commit is contained in:
Ilia Mashkov
2026-04-11 15:48:52 +03:00
parent fcde78abad
commit a526a51af8
2 changed files with 188 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
import {
layoutWithLines,
prepareWithSegments,
} from '@chenglou/pretext';
/**
* High-performance text layout engine using pretext
*/
export interface LayoutLine {
text: string;
width: number;
chars: Array<{
char: string;
x: number;
width: number;
}>;
}
export interface LayoutResult {
lines: LayoutLine[];
totalHeight: number;
}
export class TextLayoutEngine {
#segmenter: Intl.Segmenter;
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' });
}
/**
* Measure and layout text within a given width
*/
layout(text: string, font: string, width: number, lineHeight: number): LayoutResult {
if (!text) {
return { lines: [], totalHeight: 0 };
}
// Use prepareWithSegments to get segment information
const prepared = prepareWithSegments(text, font);
const { lines, height } = layoutWithLines(prepared, width, lineHeight);
// Access internal pretext data for character-level offsets
const internal = prepared as any;
const breakableFitAdvances = internal.breakableFitAdvances as (number[] | null)[];
const widths = internal.widths as number[];
const resultLines: LayoutLine[] = lines.map(line => {
const chars: LayoutLine['chars'] = [];
let currentX = 0;
const start = line.start;
const end = line.end;
// Iterate through segments in the line
for (let sIdx = start.segmentIndex; sIdx <= end.segmentIndex; sIdx++) {
const segmentText = prepared.segments[sIdx];
if (segmentText === undefined) continue;
// Get graphemes for this 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 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];
// advances is null for single-grapheme or non-breakable segments.
// In both cases the whole segment width belongs to the single grapheme.
const charWidth = advances != null ? advances[gIdx]! : widths[sIdx]!;
chars.push({
char,
x: currentX,
width: charWidth,
});
currentX += charWidth;
}
}
return {
text: line.text,
width: line.width,
chars,
};
});
return {
lines: resultLines,
totalHeight: height,
};
}
}