Files
paseo/packages/app/src/git/diff-document/text-measurement.ts
T
Mohamed Boudra c689f8bb94 fix(app): stop quadratic diff text measurement freezing the renderer
Both advance-measurement paths measured a line by re-measuring a growing
prefix per grapheme, so one n-grapheme line shaped n^2/2 characters. The
web canvas measurer and the model fallback each carried a copy, and the
native Skia branch had the same shape via getRectsForRange(0, end) per
grapheme. Nothing caps line length client-side, so one long generated
line in a diff pinned the renderer main thread for minutes and the app
read as frozen while compositor animations kept running.

Advance measurement now has one owner in text-measurement.ts: 64-grapheme
chunks measured against an accumulated base, with chunk boundaries snapped
to shaping-safe seams so ligatures and contextual scripts keep their exact
prefix-measured widths (the xfi/Arabic contract in model.test.ts is
unchanged). A 1.2M-char line drops from a ~331-minute projection to under
a second; measurement-cost.test.ts pins the linear shaped-character
budget so the quadratic cannot come back.
2026-08-19 10:14:00 +02:00

176 lines
6.0 KiB
TypeScript

import type { TextMeasurer } from "./types";
export interface PrimaryTextFace {
glyphIds(text: string): number[];
measure(text: string): number;
}
export interface CachedAsciiTextMetrics {
hasEveryGlyph(text: string): boolean;
measureAdvances(graphemes: readonly string[]): number[];
}
const CODE_LIGATURE_CANDIDATE = /(?:===?|!==?|=>|<=|>=|->|::|\+\+|--)/;
export function requiresShaping(text: string): boolean {
return /[^\x20-\x7e\t]/u.test(text) || CODE_LIGATURE_CANDIDATE.test(text);
}
/**
* Longest run handed to a shaper in one piece.
*
* Every backend that can only measure a whole string costs O(run length) per
* cumulative advance, so measuring a run of n graphemes shapes n^2/2 characters.
* A diff line is not bounded by anything -- a minified bundle checked into the
* repo produces single lines over a megabyte long, which shapes for hours. Runs
* are therefore capped, which trades a bounded seam error at chunk boundaries
* for linear cost.
*/
const SHAPED_RUN_LIMIT = 64;
/** How far a chunk boundary may slide back to land where shaping cannot join. */
const BOUNDARY_SEARCH_LIMIT = 16;
/** Characters that code fonts fuse into ligature glyphs. */
const LIGATURE_JOINING = /[=!<>+\-:&|*/~^?.]/;
export interface ShapedAdvances {
/** True when `text` needs a real shaper: per-grapheme advances are not additive. */
requiresShaping(text: string): boolean;
/** Cumulative advances summed from per-grapheme widths. */
measureAdditive(graphemes: readonly string[]): number[];
/** Cumulative advances for the whole run, measured in one shaping context. */
measureShaped(graphemes: readonly string[]): number[];
}
export function createChunkedAdvanceMeasurer(
advances: ShapedAdvances,
): (graphemes: readonly string[]) => number[] {
return (graphemes) => {
const result = Array.from<number>({ length: graphemes.length });
let base = 0;
let start = 0;
while (start < graphemes.length) {
const end = chunkEnd(graphemes, start);
const chunk = graphemes.slice(start, end);
const measured = advances.requiresShaping(chunk.join(""))
? advances.measureShaped(chunk)
: advances.measureAdditive(chunk);
for (let index = 0; index < chunk.length; index += 1) {
result[start + index] = base + (measured[index] ?? 0);
}
base = result[end - 1] ?? base;
start = end;
}
return result;
};
}
/**
* Advance measurement for a backend that can only measure a whole string, which
* is every backend except a native paragraph shaper.
*/
export function createMeasuredAdvances(
measure: (text: string) => number,
): (graphemes: readonly string[]) => number[] {
return createChunkedAdvanceMeasurer({
requiresShaping,
measureAdditive: createAdditiveAdvances(measure),
measureShaped: (graphemes) => measurePrefixAdvances(graphemes, measure),
});
}
const measurerAdvances = new WeakMap<TextMeasurer, (graphemes: readonly string[]) => number[]>();
/** Advance measurement for `measurer`, defaulting backends that supply none. */
export function advancesFor(measurer: TextMeasurer): (graphemes: readonly string[]) => number[] {
const existing = measurerAdvances.get(measurer);
if (existing) return existing;
const resolved = measurer.measureAdvances
? measurer.measureAdvances.bind(measurer)
: createMeasuredAdvances((text) => measurer.measure(text));
measurerAdvances.set(measurer, resolved);
return resolved;
}
/** Cumulative advances summed from per-grapheme widths, each measured once. */
export function createAdditiveAdvances(
measure: (text: string) => number,
): (graphemes: readonly string[]) => number[] {
const widths = new Map<string, number>();
return (graphemes) => {
let advance = 0;
return graphemes.map((grapheme) => {
let width = widths.get(grapheme);
if (width === undefined) {
width = measure(grapheme);
widths.set(grapheme, width);
}
advance += width;
return advance;
});
};
}
export function createCachedAsciiTextMetrics(primary: PrimaryTextFace): CachedAsciiTextMetrics {
const glyphCoverage = new Map<string, boolean>();
return {
hasEveryGlyph(text) {
for (const character of text) {
let hasGlyph = glyphCoverage.get(character);
if (hasGlyph === undefined) {
hasGlyph = primary.glyphIds(character).every((glyph) => glyph !== 0);
glyphCoverage.set(character, hasGlyph);
}
if (!hasGlyph) return false;
}
return true;
},
measureAdvances: createAdditiveAdvances((text) => primary.measure(text)),
};
}
export function createFallbackAwareTextMeasurer(input: {
primary: PrimaryTextFace;
measureWithSystemFallback: (text: string) => number;
}): TextMeasurer {
return {
measure(text) {
if (text.length === 0) return 0;
const hasEveryGlyph = input.primary.glyphIds(text).every((glyph) => glyph !== 0);
return hasEveryGlyph ? input.primary.measure(text) : input.measureWithSystemFallback(text);
},
};
}
function measurePrefixAdvances(
graphemes: readonly string[],
measure: (text: string) => number,
): number[] {
let prefix = "";
return graphemes.map((grapheme) => {
prefix += grapheme;
return measure(prefix);
});
}
function chunkEnd(graphemes: readonly string[], start: number): number {
const limit = start + SHAPED_RUN_LIMIT;
if (limit >= graphemes.length) return graphemes.length;
const floor = Math.max(start + 1, limit - BOUNDARY_SEARCH_LIMIT);
for (let end = limit; end > floor; end -= 1) {
if (splitsCleanly(graphemes[end - 1]) && splitsCleanly(graphemes[end])) return end;
}
return limit;
}
/**
* Whether a boundary next to `grapheme` is one no shaper reaches across. Ligature
* glyphs are fused from ASCII punctuation and complex scripts join within a word,
* so a printable-ASCII grapheme carrying no ligature punctuation is a safe seam.
*/
function splitsCleanly(grapheme: string | undefined): boolean {
if (grapheme === undefined) return true;
return /^[\x20-\x7e]+$/.test(grapheme) && !LIGATURE_JOINING.test(grapheme);
}