Fix/text morphing position #40
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user