You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/lib/canvas-text/layout.ts

745 lines
24 KiB

// Text measurement for browser environments using canvas measureText.
//
// Problem: DOM-based text measurement (getBoundingClientRect, offsetHeight)
// forces synchronous layout reflow. When component independently measure text,
// each measurement triggers a reflow of the entire document. This creates
// read/write interleaving that can cost 30ms+ per frame for 500 text blocks.
//
// Solution: two-phase measurement centered around canvas measureText.
// prepare(text, font) — segments text via Intl.Segmenter, measures each word
// via canvas, caches widths, and does one cached DOM calibration read per
// font when emoji correction is needed. Call once when text first appears.
// layout(prepared, maxWidth, lineHeight) — walks cached word widths with pure
// arithmetic to count lines and compute height. Call on every resize.
// ~0.0002ms per text.
//
// i18n: Intl.Segmenter handles CJK (per-character breaking), Thai, Arabic, etc.
// Bidi: simplified rich-path metadata for mixed LTR/RTL custom rendering.
// Punctuation merging: "better." measured as one unit (matches CSS behavior).
// Trailing whitespace: hangs past line edge without triggering breaks (CSS behavior).
// overflow-wrap: pre-measured grapheme widths enable character-level word breaking.
//
// Emoji correction: Chrome/Firefox canvas measures emoji wider than DOM at font
// sizes <24px on macOS (Apple Color Emoji). The inflation is constant per emoji
// grapheme at a given size, font-independent. Auto-detected by comparing canvas
// vs actual DOM emoji width (one cached DOM read per font). Safari canvas and
// DOM agree (both wider than fontSize), so correction = 0 there.
//
// Limitations:
// - system-ui font: canvas resolves to different optical variants than DOM on macOS.
// Use named fonts (Helvetica, Inter, etc.) for guaranteed accuracy.
// See RESEARCH.md "Discovery: system-ui font resolution mismatch".
//
// Based on Sebastian Markbage's text-layout research (github.com/chenglou/text-layout).
import { computeSegmentLevels } from './bidi.js';
import {
analyzeText,
clearAnalysisCaches,
endsWithClosingQuote,
isCJK,
kinsokuEnd,
kinsokuStart,
leftStickyPunctuation,
setAnalysisLocale,
type AnalysisChunk,
type SegmentBreakKind,
type TextAnalysis,
type WhiteSpaceMode
} from './analysis.js';
import {
clearMeasurementCaches,
getCorrectedSegmentWidth,
getEngineProfile,
getFontMeasurementState,
getSegmentGraphemePrefixWidths,
getSegmentGraphemeWidths,
getSegmentMetrics,
textMayContainEmoji
} from './measurement.js';
import {
countPreparedLines,
layoutNextLineRange as stepPreparedLineRange,
walkPreparedLines,
type InternalLayoutLine
} from './line-break.js';
let sharedGraphemeSegmenter: Intl.Segmenter | null = null;
// Rich-path only. Reuses grapheme splits while materializing multiple lines
// from the same prepared handle, without pushing that cache into the API.
let sharedLineTextCaches = new WeakMap<PreparedTextWithSegments, Map<number, string[]>>();
function getSharedGraphemeSegmenter(): Intl.Segmenter {
if (sharedGraphemeSegmenter === null) {
sharedGraphemeSegmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' });
}
return sharedGraphemeSegmenter;
}
// --- Public types ---
declare const preparedTextBrand: unique symbol;
type PreparedCore = {
widths: number[]; // Segment widths, e.g. [42.5, 4.4, 37.2]
lineEndFitAdvances: number[]; // Width contribution when a line ends after this segment
lineEndPaintAdvances: number[]; // Painted width contribution when a line ends after this segment
kinds: SegmentBreakKind[]; // Break behavior per segment, e.g. ['text', 'space', 'text']
simpleLineWalkFastPath: boolean; // Normal text can use the simpler old line walker across all layout APIs
segLevels: Int8Array | null; // Rich-path bidi metadata for custom rendering; layout() never reads it
breakableWidths: (number[] | null)[]; // Grapheme widths for overflow-wrap segments, else null
breakablePrefixWidths: (number[] | null)[]; // Cumulative grapheme prefix widths for narrow browser-policy shims
discretionaryHyphenWidth: number; // Visible width added when a soft hyphen is chosen as the break
tabStopAdvance: number; // Absolute advance between tab stops for pre-wrap tab segments
chunks: PreparedLineChunk[]; // Precompiled hard-break chunks for line walking
};
// Keep the main prepared handle opaque so the public API does not accidentally
// calcify around the current parallel-array representation.
export type PreparedText = {
readonly [preparedTextBrand]: true;
};
type InternalPreparedText = PreparedText & PreparedCore;
// Rich/diagnostic variant that still exposes the structural segment data.
// Treat this as the unstable escape hatch for experiments and custom rendering.
export type PreparedTextWithSegments = InternalPreparedText & {
segments: string[]; // Segment text aligned with the parallel arrays, e.g. ['hello', ' ', 'world']
};
export type LayoutCursor = {
segmentIndex: number; // Segment index in `segments`
graphemeIndex: number; // Grapheme index within that segment; `0` at segment boundaries
};
export type LayoutResult = {
lineCount: number; // Number of wrapped lines, e.g. 3
height: number; // Total block height, e.g. lineCount * lineHeight = 57
};
export type LayoutLine = {
text: string; // Full text content of this line, e.g. 'hello world'
width: number; // Measured width of this line, e.g. 87.5
start: LayoutCursor; // Inclusive start cursor in prepared segments/graphemes
end: LayoutCursor; // Exclusive end cursor in prepared segments/graphemes
};
export type LayoutLineRange = {
width: number; // Measured width of this line, e.g. 87.5
start: LayoutCursor; // Inclusive start cursor in prepared segments/graphemes
end: LayoutCursor; // Exclusive end cursor in prepared segments/graphemes
};
export type LayoutLinesResult = LayoutResult & {
lines: LayoutLine[]; // Per-line text/width pairs for custom rendering
};
export type PrepareProfile = {
analysisMs: number;
measureMs: number;
totalMs: number;
analysisSegments: number;
preparedSegments: number;
breakableSegments: number;
};
export type PrepareOptions = {
whiteSpace?: WhiteSpaceMode;
/**
* Compute rich-path bidi levels (`segLevels`). Off by default: the line-break
* walker never consumes them (the DOM does its own bidi reordering), so it is
* wasted work unless a custom renderer asks for it. (fix-stext.md T5)
*/
computeBidiLevels?: boolean;
};
export type PreparedLineChunk = {
startSegmentIndex: number;
endSegmentIndex: number;
consumedEndSegmentIndex: number;
};
// --- Public API ---
function createEmptyPrepared(
includeSegments: boolean
): InternalPreparedText | PreparedTextWithSegments {
if (includeSegments) {
return {
widths: [],
lineEndFitAdvances: [],
lineEndPaintAdvances: [],
kinds: [],
simpleLineWalkFastPath: true,
segLevels: null,
breakableWidths: [],
breakablePrefixWidths: [],
discretionaryHyphenWidth: 0,
tabStopAdvance: 0,
chunks: [],
segments: []
} as unknown as PreparedTextWithSegments;
}
return {
widths: [],
lineEndFitAdvances: [],
lineEndPaintAdvances: [],
kinds: [],
simpleLineWalkFastPath: true,
segLevels: null,
breakableWidths: [],
breakablePrefixWidths: [],
discretionaryHyphenWidth: 0,
tabStopAdvance: 0,
chunks: []
} as unknown as InternalPreparedText;
}
function measureAnalysis(
analysis: TextAnalysis,
font: string,
includeSegments: boolean,
computeBidiLevels: boolean
): InternalPreparedText | PreparedTextWithSegments {
const graphemeSegmenter = getSharedGraphemeSegmenter();
const engineProfile = getEngineProfile();
const { cache, emojiCorrection } = getFontMeasurementState(
font,
textMayContainEmoji(analysis.normalized)
);
const discretionaryHyphenWidth = getCorrectedSegmentWidth(
'-',
getSegmentMetrics('-', cache),
emojiCorrection
);
const spaceWidth = getCorrectedSegmentWidth(' ', getSegmentMetrics(' ', cache), emojiCorrection);
const tabStopAdvance = spaceWidth * 8;
if (analysis.len === 0) return createEmptyPrepared(includeSegments);
const widths: number[] = [];
const lineEndFitAdvances: number[] = [];
const lineEndPaintAdvances: number[] = [];
const kinds: SegmentBreakKind[] = [];
let simpleLineWalkFastPath = analysis.chunks.length <= 1;
const segStarts = includeSegments ? ([] as number[]) : null;
const breakableWidths: (number[] | null)[] = [];
const breakablePrefixWidths: (number[] | null)[] = [];
const segments = includeSegments ? ([] as string[]) : null;
const preparedStartByAnalysisIndex = Array.from<number>({ length: analysis.len });
const preparedEndByAnalysisIndex = Array.from<number>({ length: analysis.len });
function pushMeasuredSegment(
text: string,
width: number,
lineEndFitAdvance: number,
lineEndPaintAdvance: number,
kind: SegmentBreakKind,
start: number,
breakable: number[] | null,
breakablePrefix: number[] | null
): void {
if (kind !== 'text' && kind !== 'space' && kind !== 'zero-width-break') {
simpleLineWalkFastPath = false;
}
widths.push(width);
lineEndFitAdvances.push(lineEndFitAdvance);
lineEndPaintAdvances.push(lineEndPaintAdvance);
kinds.push(kind);
segStarts?.push(start);
breakableWidths.push(breakable);
breakablePrefixWidths.push(breakablePrefix);
if (segments !== null) segments.push(text);
}
for (let mi = 0; mi < analysis.len; mi++) {
preparedStartByAnalysisIndex[mi] = widths.length;
const segText = analysis.texts[mi]!;
const segWordLike = analysis.isWordLike[mi]!;
const segKind = analysis.kinds[mi]!;
const segStart = analysis.starts[mi]!;
if (segKind === 'soft-hyphen') {
pushMeasuredSegment(
segText,
0,
discretionaryHyphenWidth,
discretionaryHyphenWidth,
segKind,
segStart,
null,
null
);
preparedEndByAnalysisIndex[mi] = widths.length;
continue;
}
if (segKind === 'hard-break') {
pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null);
preparedEndByAnalysisIndex[mi] = widths.length;
continue;
}
if (segKind === 'tab') {
pushMeasuredSegment(segText, 0, 0, 0, segKind, segStart, null, null);
preparedEndByAnalysisIndex[mi] = widths.length;
continue;
}
const segMetrics = getSegmentMetrics(segText, cache);
if (segKind === 'text' && segMetrics.containsCJK) {
let unitText = '';
let unitStart = 0;
for (const gs of graphemeSegmenter.segment(segText)) {
const grapheme = gs.segment;
if (unitText.length === 0) {
unitText = grapheme;
unitStart = gs.index;
continue;
}
if (
kinsokuEnd.has(unitText) ||
kinsokuStart.has(grapheme) ||
leftStickyPunctuation.has(grapheme) ||
(engineProfile.carryCJKAfterClosingQuote &&
isCJK(grapheme) &&
endsWithClosingQuote(unitText))
) {
unitText += grapheme;
continue;
}
const unitMetrics = getSegmentMetrics(unitText, cache);
const w = getCorrectedSegmentWidth(unitText, unitMetrics, emojiCorrection);
pushMeasuredSegment(unitText, w, w, w, 'text', segStart + unitStart, null, null);
unitText = grapheme;
unitStart = gs.index;
}
if (unitText.length > 0) {
const unitMetrics = getSegmentMetrics(unitText, cache);
const w = getCorrectedSegmentWidth(unitText, unitMetrics, emojiCorrection);
pushMeasuredSegment(unitText, w, w, w, 'text', segStart + unitStart, null, null);
}
preparedEndByAnalysisIndex[mi] = widths.length;
continue;
}
const w = getCorrectedSegmentWidth(segText, segMetrics, emojiCorrection);
const lineEndFitAdvance =
segKind === 'space' || segKind === 'preserved-space' || segKind === 'zero-width-break'
? 0
: w;
const lineEndPaintAdvance = segKind === 'space' || segKind === 'zero-width-break' ? 0 : w;
if (segWordLike && segText.length > 1) {
const graphemeWidths = getSegmentGraphemeWidths(segText, segMetrics, cache, emojiCorrection);
const graphemePrefixWidths = engineProfile.preferPrefixWidthsForBreakableRuns
? getSegmentGraphemePrefixWidths(segText, segMetrics, cache, emojiCorrection)
: null;
pushMeasuredSegment(
segText,
w,
lineEndFitAdvance,
lineEndPaintAdvance,
segKind,
segStart,
graphemeWidths,
graphemePrefixWidths
);
} else {
pushMeasuredSegment(
segText,
w,
lineEndFitAdvance,
lineEndPaintAdvance,
segKind,
segStart,
null,
null
);
}
preparedEndByAnalysisIndex[mi] = widths.length;
}
const chunks = mapAnalysisChunksToPreparedChunks(
analysis.chunks,
preparedStartByAnalysisIndex,
preparedEndByAnalysisIndex
);
// Opt-in (fix-stext.md T5): the walker never reads segLevels; skip the cost
// unless a custom renderer explicitly asks for bidi metadata.
const segLevels =
segStarts === null || !computeBidiLevels
? null
: computeSegmentLevels(analysis.normalized, segStarts);
if (segments !== null) {
return {
widths,
lineEndFitAdvances,
lineEndPaintAdvances,
kinds,
simpleLineWalkFastPath,
segLevels,
breakableWidths,
breakablePrefixWidths,
discretionaryHyphenWidth,
tabStopAdvance,
chunks,
segments
} as unknown as PreparedTextWithSegments;
}
return {
widths,
lineEndFitAdvances,
lineEndPaintAdvances,
kinds,
simpleLineWalkFastPath,
segLevels,
breakableWidths,
breakablePrefixWidths,
discretionaryHyphenWidth,
tabStopAdvance,
chunks
} as unknown as InternalPreparedText;
}
function mapAnalysisChunksToPreparedChunks(
chunks: AnalysisChunk[],
preparedStartByAnalysisIndex: number[],
preparedEndByAnalysisIndex: number[]
): PreparedLineChunk[] {
const preparedChunks: PreparedLineChunk[] = [];
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i]!;
const startSegmentIndex =
chunk.startSegmentIndex < preparedStartByAnalysisIndex.length
? preparedStartByAnalysisIndex[chunk.startSegmentIndex]!
: (preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0);
const endSegmentIndex =
chunk.endSegmentIndex < preparedStartByAnalysisIndex.length
? preparedStartByAnalysisIndex[chunk.endSegmentIndex]!
: (preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0);
const consumedEndSegmentIndex =
chunk.consumedEndSegmentIndex < preparedStartByAnalysisIndex.length
? preparedStartByAnalysisIndex[chunk.consumedEndSegmentIndex]!
: (preparedEndByAnalysisIndex[preparedEndByAnalysisIndex.length - 1] ?? 0);
preparedChunks.push({
startSegmentIndex,
endSegmentIndex,
consumedEndSegmentIndex
});
}
return preparedChunks;
}
function prepareInternal(
text: string,
font: string,
includeSegments: boolean,
options?: PrepareOptions
): InternalPreparedText | PreparedTextWithSegments {
const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace);
return measureAnalysis(analysis, font, includeSegments, options?.computeBidiLevels ?? false);
}
// Diagnostic-only helper used by the browser benchmark harness to separate the
// text-analysis and measurement phases without duplicating the prepare logic.
export function profilePrepare(
text: string,
font: string,
options?: PrepareOptions
): PrepareProfile {
const t0 = performance.now();
const analysis = analyzeText(text, getEngineProfile(), options?.whiteSpace);
const t1 = performance.now();
const prepared = measureAnalysis(analysis, font, false, false) as InternalPreparedText;
const t2 = performance.now();
let breakableSegments = 0;
for (const widths of prepared.breakableWidths) {
if (widths !== null) breakableSegments++;
}
return {
analysisMs: t1 - t0,
measureMs: t2 - t1,
totalMs: t2 - t0,
analysisSegments: analysis.len,
preparedSegments: prepared.widths.length,
breakableSegments
};
}
// Prepare text for layout. Segments the text, measures each segment via canvas,
// and stores the widths for fast relayout at any width. Call once per text block
// (e.g. when a comment first appears). The result is width-independent — the
// same PreparedText can be laid out at any maxWidth and lineHeight via layout().
//
// Steps:
// 1. Normalize collapsible whitespace (CSS white-space: normal behavior)
// 2. Segment via Intl.Segmenter (handles CJK, Thai, etc.)
// 3. Merge punctuation into preceding word ("better." as one unit)
// 4. Split CJK words into individual graphemes (per-character line breaks)
// 5. Measure each segment via canvas measureText, cache by (segment, font)
// 6. Pre-measure graphemes of long words (for overflow-wrap: break-word)
// 7. Correct emoji canvas inflation (auto-detected per font size)
// 8. Optionally compute rich-path bidi metadata for custom renderers
export function prepare(text: string, font: string, options?: PrepareOptions): PreparedText {
return prepareInternal(text, font, false, options) as PreparedText;
}
// Rich variant used by callers that need enough information to render the
// laid-out lines themselves.
export function prepareWithSegments(
text: string,
font: string,
options?: PrepareOptions
): PreparedTextWithSegments {
return prepareInternal(text, font, true, options) as PreparedTextWithSegments;
}
function getInternalPrepared(prepared: PreparedText): InternalPreparedText {
return prepared as InternalPreparedText;
}
// Layout prepared text at a given max width and caller-provided lineHeight.
// Pure arithmetic on cached widths — no canvas calls, no DOM reads, no string
// operations, no allocations.
// ~0.0002ms per text block. Call on every resize.
//
// Line breaking rules (matching CSS white-space: normal + overflow-wrap: break-word):
// - Break before any non-space segment that would overflow the line
// - Trailing whitespace hangs past the line edge (doesn't trigger breaks)
// - Segments wider than maxWidth are broken at grapheme boundaries
export function layout(prepared: PreparedText, maxWidth: number, lineHeight: number): LayoutResult {
// Keep the resize hot path specialized. `layoutWithLines()` shares the same
// break semantics but also tracks line ranges; the extra bookkeeping is too
// expensive to pay on every hot-path `layout()` call.
const lineCount = countPreparedLines(getInternalPrepared(prepared), maxWidth);
return { lineCount, height: lineCount * lineHeight };
}
function getSegmentGraphemes(
segmentIndex: number,
segments: string[],
cache: Map<number, string[]>
): string[] {
let graphemes = cache.get(segmentIndex);
if (graphemes !== undefined) return graphemes;
graphemes = [];
const graphemeSegmenter = getSharedGraphemeSegmenter();
for (const gs of graphemeSegmenter.segment(segments[segmentIndex]!)) {
graphemes.push(gs.segment);
}
cache.set(segmentIndex, graphemes);
return graphemes;
}
function getLineTextCache(prepared: PreparedTextWithSegments): Map<number, string[]> {
let cache = sharedLineTextCaches.get(prepared);
if (cache !== undefined) return cache;
cache = new Map<number, string[]>();
sharedLineTextCaches.set(prepared, cache);
return cache;
}
function lineHasDiscretionaryHyphen(
kinds: SegmentBreakKind[],
startSegmentIndex: number,
startGraphemeIndex: number,
endSegmentIndex: number
): boolean {
return (
endSegmentIndex > 0 &&
kinds[endSegmentIndex - 1] === 'soft-hyphen' &&
!(startSegmentIndex === endSegmentIndex && startGraphemeIndex > 0)
);
}
function buildLineTextFromRange(
segments: string[],
kinds: SegmentBreakKind[],
cache: Map<number, string[]>,
startSegmentIndex: number,
startGraphemeIndex: number,
endSegmentIndex: number,
endGraphemeIndex: number
): string {
let text = '';
const endsWithDiscretionaryHyphen = lineHasDiscretionaryHyphen(
kinds,
startSegmentIndex,
startGraphemeIndex,
endSegmentIndex
);
for (let i = startSegmentIndex; i < endSegmentIndex; i++) {
if (kinds[i] === 'soft-hyphen' || kinds[i] === 'hard-break') continue;
if (i === startSegmentIndex && startGraphemeIndex > 0) {
text += getSegmentGraphemes(i, segments, cache).slice(startGraphemeIndex).join('');
} else {
text += segments[i]!;
}
}
if (endGraphemeIndex > 0) {
if (endsWithDiscretionaryHyphen) text += '-';
text += getSegmentGraphemes(endSegmentIndex, segments, cache)
.slice(startSegmentIndex === endSegmentIndex ? startGraphemeIndex : 0, endGraphemeIndex)
.join('');
} else if (endsWithDiscretionaryHyphen) {
text += '-';
}
return text;
}
function createLayoutLine(
prepared: PreparedTextWithSegments,
cache: Map<number, string[]>,
width: number,
startSegmentIndex: number,
startGraphemeIndex: number,
endSegmentIndex: number,
endGraphemeIndex: number
): LayoutLine {
return {
text: buildLineTextFromRange(
prepared.segments,
prepared.kinds,
cache,
startSegmentIndex,
startGraphemeIndex,
endSegmentIndex,
endGraphemeIndex
),
width,
start: {
segmentIndex: startSegmentIndex,
graphemeIndex: startGraphemeIndex
},
end: {
segmentIndex: endSegmentIndex,
graphemeIndex: endGraphemeIndex
}
};
}
function materializeLayoutLine(
prepared: PreparedTextWithSegments,
cache: Map<number, string[]>,
line: InternalLayoutLine
): LayoutLine {
return createLayoutLine(
prepared,
cache,
line.width,
line.startSegmentIndex,
line.startGraphemeIndex,
line.endSegmentIndex,
line.endGraphemeIndex
);
}
function toLayoutLineRange(line: InternalLayoutLine): LayoutLineRange {
return {
width: line.width,
start: {
segmentIndex: line.startSegmentIndex,
graphemeIndex: line.startGraphemeIndex
},
end: {
segmentIndex: line.endSegmentIndex,
graphemeIndex: line.endGraphemeIndex
}
};
}
function stepLineRange(
prepared: PreparedTextWithSegments,
start: LayoutCursor,
maxWidth: number
): LayoutLineRange | null {
const line = stepPreparedLineRange(prepared, start, maxWidth);
if (line === null) return null;
return toLayoutLineRange(line);
}
function materializeLine(prepared: PreparedTextWithSegments, line: LayoutLineRange): LayoutLine {
return createLayoutLine(
prepared,
getLineTextCache(prepared),
line.width,
line.start.segmentIndex,
line.start.graphemeIndex,
line.end.segmentIndex,
line.end.graphemeIndex
);
}
// Batch low-level line geometry pass. This is the non-materializing counterpart
// to layoutWithLines(), useful for shrinkwrap and other aggregate geometry work.
export function walkLineRanges(
prepared: PreparedTextWithSegments,
maxWidth: number,
onLine: (line: LayoutLineRange) => void
): number {
if (prepared.widths.length === 0) return 0;
return walkPreparedLines(getInternalPrepared(prepared), maxWidth, (line) => {
onLine(toLayoutLineRange(line));
});
}
export function layoutNextLine(
prepared: PreparedTextWithSegments,
start: LayoutCursor,
maxWidth: number
): LayoutLine | null {
const line = stepLineRange(prepared, start, maxWidth);
if (line === null) return null;
return materializeLine(prepared, line);
}
// Rich layout API for callers that want the actual line contents and widths.
// Caller still supplies lineHeight at layout time. Mirrors layout()'s break
// decisions, but keeps extra per-line bookkeeping so it should stay off the
// resize hot path.
export function layoutWithLines(
prepared: PreparedTextWithSegments,
maxWidth: number,
lineHeight: number
): LayoutLinesResult {
const lines: LayoutLine[] = [];
if (prepared.widths.length === 0) return { lineCount: 0, height: 0, lines };
const graphemeCache = getLineTextCache(prepared);
const lineCount = walkPreparedLines(getInternalPrepared(prepared), maxWidth, (line) => {
lines.push(materializeLayoutLine(prepared, graphemeCache, line));
});
return { lineCount, height: lineCount * lineHeight, lines };
}
export function clearCache(): void {
clearAnalysisCaches();
sharedGraphemeSegmenter = null;
sharedLineTextCaches = new WeakMap<PreparedTextWithSegments, Map<number, string[]>>();
clearMeasurementCaches();
}
export function setLocale(locale?: string): void {
setAnalysisLocale(locale);
clearCache();
}

Powered by TurnKey Linux.