diff --git a/src/shared/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts b/src/shared/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts new file mode 100644 index 0000000..b8d2709 --- /dev/null +++ b/src/shared/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts @@ -0,0 +1,71 @@ +/** + * Ensures a set of fonts is usable in a `` 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 { + 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(); + 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(resolve => requestAnimationFrame(() => resolve())); + } +} diff --git a/src/shared/lib/utils/index.ts b/src/shared/lib/utils/index.ts index 154dba7..51e97a4 100644 --- a/src/shared/lib/utils/index.ts +++ b/src/shared/lib/utils/index.ts @@ -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';