diff --git a/src/widgets/ComparisonView/lib/index.ts b/src/widgets/ComparisonView/lib/index.ts index 1d97db6..d4cccf6 100644 --- a/src/widgets/ComparisonView/lib/index.ts +++ b/src/widgets/ComparisonView/lib/index.ts @@ -1,2 +1,3 @@ export * from './utils/dotTransition'; -export * from './utils/getPretextFontString'; +export * from './utils/ensureCanvasFonts/ensureCanvasFonts'; +export * from './utils/getPretextFontString/getPretextFontString'; diff --git a/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.test.ts b/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.test.ts new file mode 100644 index 0000000..70118cf --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.test.ts @@ -0,0 +1,301 @@ +import { + afterEach, + describe, + expect, + it, + vi, +} from 'vitest'; +import { getPretextFontString } from '../getPretextFontString/getPretextFontString'; +import { ensureCanvasFonts } from './ensureCanvasFonts'; + +const FALLBACK_FAMILY = '__glyphdiff_no_such_font_42__'; +const fallbackFont = (sizePx: number) => getPretextFontString(400, sizePx, FALLBACK_FAMILY); + +/** + * Fake Canvas2D context that returns a scripted width per font string. + * Tracks how many times measureText was called so tests can assert polling + * behavior without depending on wall-clock time. + */ +function createFakeCtx() { + const widthsByFont = new Map number)>(); + const measureCalls: Array<{ font: string; text: string }> = []; + const ctx = { + font: '', + measureText(text: string) { + measureCalls.push({ font: ctx.font, text }); + const entry = widthsByFont.get(ctx.font); + const width = typeof entry === 'function' ? entry() : entry ?? 0; + return { width }; + }, + }; + return { + ctx: ctx as unknown as CanvasRenderingContext2D, + widthsByFont, + measureCalls, + }; +} + +interface MockGlobals { + fontsLoad: ReturnType; + rafCalls: number; + nowValues: number[]; + nowIndex: { current: number }; + restore: () => void; +} + +function installGlobals(opts: { + /** Sequence of values returned by performance.now(); last value repeats. */ + nowSequence: number[]; + /** If true, OffscreenCanvas is defined and getContext returns the fake ctx. */ + useOffscreenCanvas: boolean; + ctx: CanvasRenderingContext2D | null; +}): MockGlobals { + const fontsLoad = vi.fn().mockResolvedValue([]); + + const originals: Array<[string, PropertyDescriptor | undefined]> = []; + const setGlobal = (key: string, value: unknown) => { + originals.push([key, Object.getOwnPropertyDescriptor(globalThis, key)]); + Object.defineProperty(globalThis, key, { + configurable: true, + writable: true, + value, + }); + }; + + setGlobal('document', { + fonts: { load: fontsLoad }, + createElement: vi.fn(() => ({ + getContext: vi.fn(() => opts.ctx), + })), + }); + + if (opts.useOffscreenCanvas) { + class FakeOffscreenCanvas { + getContext() { + return opts.ctx; + } + } + setGlobal('OffscreenCanvas', FakeOffscreenCanvas); + } else { + setGlobal('OffscreenCanvas', undefined); + } + + const nowIndex = { current: 0 }; + setGlobal('performance', { + now: () => opts.nowSequence[Math.min(nowIndex.current++, opts.nowSequence.length - 1)], + }); + + let rafCount = 0; + const rafState = { count: 0 }; + setGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => { + rafState.count++; + rafCount++; + Promise.resolve().then(() => cb(0)); + return rafCount; + }); + + return { + fontsLoad, + get rafCalls() { + return rafState.count; + }, + nowValues: opts.nowSequence, + nowIndex, + restore() { + for (const [key, desc] of originals) { + if (desc) { + Object.defineProperty(globalThis, key, desc); + } else { + delete (globalThis as any)[key]; + } + } + }, + } as MockGlobals; +} + +describe('ensureCanvasFonts', () => { + let cleanup: (() => void) | undefined; + afterEach(() => { + cleanup?.(); + cleanup = undefined; + }); + + it('awaits document.fonts.load for every font string', async () => { + const { ctx, widthsByFont } = createFakeCtx(); + const fontA = getPretextFontString(400, 36, 'Roboto'); + const fontB = getPretextFontString(400, 36, 'Smooch Sans'); + // Real font width clearly differs from the unknown-family fallback width. + widthsByFont.set(fontA, 200); + widthsByFont.set(fontB, 130); + // The fallback probe uses the same size with an unknown family. + widthsByFont.set(fallbackFont(36), 280); + + const mocks = installGlobals({ + nowSequence: [0, 5], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([fontA, fontB]); + + expect(mocks.fontsLoad).toHaveBeenCalledTimes(2); + expect(mocks.fontsLoad).toHaveBeenCalledWith(fontA); + expect(mocks.fontsLoad).toHaveBeenCalledWith(fontB); + }); + + it('returns without polling when fonts already measure as non-fallback', async () => { + const { ctx, widthsByFont } = createFakeCtx(); + const font = getPretextFontString(400, 36, 'Roboto'); + widthsByFont.set(font, 200); + widthsByFont.set(fallbackFont(36), 280); + + const mocks = installGlobals({ + nowSequence: [0, 5], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([font]); + + // First iteration succeeds → no rAF needed + expect(mocks.rafCalls).toBe(0); + }); + + it('polls via requestAnimationFrame until measurement diverges from fallback', async () => { + const { ctx, widthsByFont, measureCalls } = createFakeCtx(); + const font = getPretextFontString(400, 36, 'Roboto'); + widthsByFont.set(fallbackFont(36), 280); + // Roboto reports the fallback width for the first two reads, then resolves. + let robotoReads = 0; + widthsByFont.set(font, () => { + robotoReads++; + return robotoReads <= 2 ? 280 : 200; + }); + + // Provide enough now() values for: initial fallback measurement + + // multiple loop iterations within the deadline. + const mocks = installGlobals({ + nowSequence: [0, 10, 20, 30, 40, 50], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([font]); + + // Two iterations failed → two rAF awaits before success on the third. + expect(mocks.rafCalls).toBe(2); + // Measurement was called once for the fallback probe + three poll attempts. + const robotoCalls = measureCalls.filter(c => c.font === font).length; + expect(robotoCalls).toBe(3); + }); + + it('exits when performance.now passes the 1s deadline even if fonts never load', async () => { + const { ctx, widthsByFont } = createFakeCtx(); + const font = getPretextFontString(400, 36, 'NeverLoads'); + widthsByFont.set(fallbackFont(36), 280); + // Always returns fallback width → poll never finds a divergence. + widthsByFont.set(font, 280); + + const mocks = installGlobals({ + // Start at 0, then the next check jumps past the 1000ms deadline. + nowSequence: [0, 0, 1001], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await expect(ensureCanvasFonts([font])).resolves.toBeUndefined(); + }); + + it('returns early when no canvas context is available', async () => { + const mocks = installGlobals({ + nowSequence: [0], + useOffscreenCanvas: false, + ctx: null, + }); + cleanup = mocks.restore; + + await expect( + ensureCanvasFonts([getPretextFontString(400, 16, 'X')]), + ).resolves.toBeUndefined(); + // fonts.load still ran; just no canvas polling. + expect(mocks.fontsLoad).toHaveBeenCalledTimes(1); + expect(mocks.rafCalls).toBe(0); + }); + + it('falls back to a DOM canvas when OffscreenCanvas is unavailable', async () => { + const { ctx, widthsByFont } = createFakeCtx(); + const font = getPretextFontString(400, 36, 'Roboto'); + widthsByFont.set(font, 200); + widthsByFont.set(fallbackFont(36), 280); + + const mocks = installGlobals({ + nowSequence: [0, 5], + useOffscreenCanvas: false, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([font]); + + expect((globalThis as any).document.createElement).toHaveBeenCalledWith('canvas'); + }); + + it('uses the font size from each font string for the fallback probe', async () => { + const { ctx, widthsByFont, measureCalls } = createFakeCtx(); + const fontA = getPretextFontString(400, 24, 'FontA'); + const fontB = getPretextFontString(700, 48, 'FontB'); + widthsByFont.set(fallbackFont(24), 150); + widthsByFont.set(fallbackFont(48), 360); + widthsByFont.set(fontA, 100); + widthsByFont.set(fontB, 200); + + const mocks = installGlobals({ + nowSequence: [0, 5], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([fontA, fontB]); + + const fallbackFonts = measureCalls + .map(c => c.font) + .filter(f => f.includes(FALLBACK_FAMILY)); + expect(fallbackFonts).toContain(fallbackFont(24)); + expect(fallbackFonts).toContain(fallbackFont(48)); + }); + + it('removes a font from the pending set as soon as it diverges, leaving others to poll', async () => { + const { ctx, widthsByFont, measureCalls } = createFakeCtx(); + const fontA = getPretextFontString(400, 36, 'A'); + const fontB = getPretextFontString(400, 36, 'B'); + widthsByFont.set(fallbackFont(36), 280); + // A loads immediately; B takes one extra frame. + widthsByFont.set(fontA, 200); + let bReads = 0; + widthsByFont.set(fontB, () => { + bReads++; + return bReads === 1 ? 280 : 150; + }); + + const mocks = installGlobals({ + nowSequence: [0, 10, 20, 30, 40], + useOffscreenCanvas: true, + ctx, + }); + cleanup = mocks.restore; + + await ensureCanvasFonts([fontA, fontB]); + + // A measured once (resolved iter 1). B measured twice (iter 1 fallback, iter 2 real). + const aCalls = measureCalls.filter(c => c.font === fontA).length; + const bCalls = measureCalls.filter(c => c.font === fontB).length; + expect(aCalls).toBe(1); + expect(bCalls).toBe(2); + expect(mocks.rafCalls).toBe(1); + }); +}); diff --git a/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts b/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts new file mode 100644 index 0000000..023acc5 --- /dev/null +++ b/src/widgets/ComparisonView/lib/utils/ensureCanvasFonts/ensureCanvasFonts.ts @@ -0,0 +1,62 @@ +/** + * 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 + * comparison morph boundary drifts visibly from the thumb. 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. + */ +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; + } + await new Promise(resolve => requestAnimationFrame(() => resolve())); + } +} diff --git a/src/widgets/ComparisonView/lib/utils/getPretextFontString.test.ts b/src/widgets/ComparisonView/lib/utils/getPretextFontString/getPretextFontString.test.ts similarity index 100% rename from src/widgets/ComparisonView/lib/utils/getPretextFontString.test.ts rename to src/widgets/ComparisonView/lib/utils/getPretextFontString/getPretextFontString.test.ts diff --git a/src/widgets/ComparisonView/lib/utils/getPretextFontString.ts b/src/widgets/ComparisonView/lib/utils/getPretextFontString/getPretextFontString.ts similarity index 100% rename from src/widgets/ComparisonView/lib/utils/getPretextFontString.ts rename to src/widgets/ComparisonView/lib/utils/getPretextFontString/getPretextFontString.ts diff --git a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte index 3b03a87..2fb6ed6 100644 --- a/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte +++ b/src/widgets/ComparisonView/ui/SliderArea/SliderArea.svelte @@ -22,7 +22,10 @@ import { Loader } from '$shared/ui'; import { getContext } from 'svelte'; import { Spring } from 'svelte/motion'; import { fade } from 'svelte/transition'; -import { getPretextFontString } from '../../lib'; +import { + ensureCanvasFonts, + getPretextFontString, +} from '../../lib'; import { comparisonStore } from '../../model'; import Character from '../Character/Character.svelte'; import Line from '../Line/Line.svelte'; @@ -144,7 +147,10 @@ $effect(() => { } }); -// Layout effect — depends on content, settings AND containerWidth +// Layout effect — depends on content, settings AND containerWidth. +// Awaits font loading into the canvas measurement context before invoking +// the engine; otherwise pretext caches fallback-font widths globally per +// font string, and the morph boundary drifts from the thumb visually. $effect(() => { const _text = comparisonStore.text; const _weight = typography.weight; @@ -154,15 +160,22 @@ $effect(() => { const _width = containerWidth; const _isMobile = isMobile; - if (container && fontA && fontB && _width > 0) { - // PRETEXT API strings: "weight sizepx family" - const fontAStr = getPretextFontString(_weight, _size, fontA.name); - const fontBStr = getPretextFontString(_weight, _size, fontB.name); + if (!container || !fontA || !fontB || _width <= 0) { + return; + } - const padding = _isMobile ? 48 : 96; - const availableWidth = Math.max(0, _width - padding); - const lineHeight = _size * _height; + const fontAStr = getPretextFontString(_weight, _size, fontA.name); + const fontBStr = getPretextFontString(_weight, _size, fontB.name); + const padding = _isMobile ? 48 : 96; + const availableWidth = Math.max(0, _width - padding); + const lineHeight = _size * _height; + + let cancelled = false; + ensureCanvasFonts([fontAStr, fontBStr]).then(() => { + if (cancelled) { + return; + } layoutResult = comparisonEngine.layout( _text, fontAStr, @@ -172,7 +185,11 @@ $effect(() => { _spacing, _size, ); - } + }); + + return () => { + cancelled = true; + }; }); // Dynamic backgroundSize based on isMobile — can't express this in Tailwind.