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:
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { clearCache } from '@chenglou/pretext';
|
||||||
|
import {
|
||||||
|
beforeEach,
|
||||||
|
describe,
|
||||||
|
expect,
|
||||||
|
it,
|
||||||
|
} from 'vitest';
|
||||||
|
import { installCanvasMock } from '../__mocks__/canvas';
|
||||||
|
import { TextLayoutEngine } from './TextLayoutEngine.svelte';
|
||||||
|
|
||||||
|
// Fixed-width mock: every segment is measured as (text.length * 10) px.
|
||||||
|
// This is font-independent so we can reason about wrapping precisely.
|
||||||
|
const CHAR_WIDTH = 10;
|
||||||
|
|
||||||
|
describe('TextLayoutEngine', () => {
|
||||||
|
let engine: TextLayoutEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Install mock BEFORE any prepareWithSegments call.
|
||||||
|
// clearMeasurementCaches resets pretext's cached canvas context
|
||||||
|
// and segment metric caches so each test gets a clean slate.
|
||||||
|
installCanvasMock((_font, text) => text.length * CHAR_WIDTH);
|
||||||
|
clearCache();
|
||||||
|
engine = new TextLayoutEngine();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty result for empty string', () => {
|
||||||
|
const result = engine.layout('', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(0);
|
||||||
|
expect(result.totalHeight).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a single line when text fits within width', () => {
|
||||||
|
// 'ABC' = 3 chars × 10px = 30px, fits in 500px
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
expect(result.lines).toHaveLength(1);
|
||||||
|
expect(result.lines[0].text).toBe('ABC');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('breaks text into multiple lines when it exceeds width', () => {
|
||||||
|
// 'Hello World' — pretext will split at the space.
|
||||||
|
// 'Hello' = 50px, ' ' hangs, 'World' = 50px. Width = 60px forces wrap after 'Hello '.
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
expect(result.lines.length).toBeGreaterThan(1);
|
||||||
|
// First line must not exceed the container width.
|
||||||
|
expect(result.lines[0].width).toBeLessThanOrEqual(60);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns correct x positions to characters on a single line', () => {
|
||||||
|
// 'ABC': A=10px, B=10px, C=10px; all on one line in 500px container.
|
||||||
|
const result = engine.layout('ABC', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
|
||||||
|
expect(chars).toHaveLength(3);
|
||||||
|
expect(chars[0].char).toBe('A');
|
||||||
|
expect(chars[0].x).toBe(0);
|
||||||
|
expect(chars[0].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[1].char).toBe('B');
|
||||||
|
expect(chars[1].x).toBe(CHAR_WIDTH);
|
||||||
|
expect(chars[1].width).toBe(CHAR_WIDTH);
|
||||||
|
|
||||||
|
expect(chars[2].char).toBe('C');
|
||||||
|
expect(chars[2].x).toBe(CHAR_WIDTH * 2);
|
||||||
|
expect(chars[2].width).toBe(CHAR_WIDTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('x positions are monotonically increasing across a line', () => {
|
||||||
|
const result = engine.layout('ABCDE', '400 16px "Inter"', 500, 20);
|
||||||
|
const chars = result.lines[0].chars;
|
||||||
|
for (let i = 1; i < chars.length; i++) {
|
||||||
|
expect(chars[i].x).toBeGreaterThan(chars[i - 1].x);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each line has at least one char', () => {
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, 20);
|
||||||
|
for (const line of result.lines) {
|
||||||
|
expect(line.chars.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('totalHeight equals lineCount * lineHeight', () => {
|
||||||
|
const lineHeight = 24;
|
||||||
|
const result = engine.layout('Hello World', '400 16px "Inter"', 60, lineHeight);
|
||||||
|
expect(result.totalHeight).toBe(result.lines.length * lineHeight);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user