Compare commits

...

2 Commits

Author SHA1 Message Date
Ilia Mashkov f762a09c23 fix(SliderArea): temporarily replace pretext measurements with canvas
Workflow / build (pull_request) Successful in 1m53s
Workflow / publish (pull_request) Has been skipped
2026-05-23 20:07:39 +03:00
Ilia Mashkov 95ae72719e chore: move getPretextFontString into separate directory 2026-05-23 20:03:13 +03:00
6 changed files with 392 additions and 11 deletions
+2 -1
View File
@@ -1,2 +1,3 @@
export * from './utils/dotTransition';
export * from './utils/getPretextFontString';
export * from './utils/ensureCanvasFonts/ensureCanvasFonts';
export * from './utils/getPretextFontString/getPretextFontString';
@@ -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<string, number | (() => 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<typeof vi.fn>;
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);
});
});
@@ -0,0 +1,62 @@
/**
* 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
* 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<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;
}
await new Promise<void>(resolve => requestAnimationFrame(() => resolve()));
}
}
@@ -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.