feat(shared): add ensureCanvasFonts canvas-warm helper

This commit is contained in:
Ilia Mashkov
2026-06-24 13:49:13 +03:00
parent dbd48b287d
commit 0e9288c295
2 changed files with 72 additions and 0 deletions
@@ -0,0 +1,71 @@
/**
* Ensures a set of fonts is usable in a `<canvas>` measurement context.
*
* `document.fonts.load()` resolves once the FontFace bytes are fetched and
* parsed, but Chrome lazily registers fonts with the canvas measurement engine
* after that — `measureText` keeps returning a fallback width for some frames
* even though `document.fonts.check()` reports the font as loaded.
*
* Pretext caches measurements per font string forever, so a single fallback
* measurement during initial mount permanently poisons the cache and the
* rendered text drifts visibly from its measured box. This helper polls canvas
* measurement until each font reports a width that differs from the "unknown
* font family" fallback, guaranteeing the next `measureText` call sees the real
* glyph metrics.
*
* ponytail: deliberate copy of widgets/ComparisonView/lib's version — ADR-0002
* keeps the shelved morph tool untouched, so we don't move its util. The poll
* logic is the proven fix for Pretext's fallback-width cache poisoning; copying
* it is cheaper than refactoring frozen code.
*
* @param fontStrings - Pretext/canvas font strings (`weight sizepx "family"`) to warm.
*/
import { getPretextFontString } from '../getPretextFontString/getPretextFontString';
const PROBE_TEXT = 'mmmmmmmmmm';
const MAX_WAIT_MS = 1000;
const DEFAULT_PROBE_SIZE_PX = 16;
// Family unlikely to exist in any system — gives canvas's "unknown font" fallback width.
const FALLBACK_PROBE_FAMILY = '__glyphdiff_no_such_font_42__';
export async function ensureCanvasFonts(fontStrings: string[]): Promise<void> {
await Promise.all(fontStrings.map(f => document.fonts.load(f)));
// Pretext uses OffscreenCanvas when available; DOM canvas has separate font
// registration timing, so we MUST poll using the same canvas type pretext does.
const ctx = typeof OffscreenCanvas !== 'undefined'
? new OffscreenCanvas(1, 1).getContext('2d')
: document.createElement('canvas').getContext('2d');
if (!ctx) {
return;
}
// Measure each font's "unknown font" fallback width (different per browser, per OS).
// Canvas uses this same fallback for any font family it can't resolve, so when the
// requested font finally registers, measureText will return a non-fallback width.
const fallbackWidths = new Map<string, number>();
for (const font of fontStrings) {
const sizeMatch = font.match(/(\d+(?:\.\d+)?)px/);
const sizePx = sizeMatch ? parseFloat(sizeMatch[1]) : DEFAULT_PROBE_SIZE_PX;
ctx.font = getPretextFontString(400, sizePx, FALLBACK_PROBE_FAMILY);
fallbackWidths.set(font, ctx.measureText(PROBE_TEXT).width);
}
const deadline = performance.now() + MAX_WAIT_MS;
const pending = new Set(fontStrings);
while (pending.size > 0 && performance.now() < deadline) {
for (const font of Array.from(pending)) {
ctx.font = font;
const w = ctx.measureText(PROBE_TEXT).width;
if (Math.abs(w - fallbackWidths.get(font)!) > 0.5) {
pending.delete(font);
}
}
if (pending.size === 0) {
break;
}
// Sequential by design: poll once per animation frame until fonts register.
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
+1
View File
@@ -17,6 +17,7 @@ export {
export { clampNumber } from './clampNumber/clampNumber';
export { cn } from './cn';
export { debounce } from './debounce/debounce';
export { ensureCanvasFonts } from './ensureCanvasFonts/ensureCanvasFonts';
export { getDecimalPlaces } from './getDecimalPlaces/getDecimalPlaces';
export { getPretextFontString } from './getPretextFontString/getPretextFontString';
export { getSkeletonWidth } from './getSkeletonWidth/getSkeletonWidth';