From b5ab8e22070dfd5b2f8e3fb8d9f3947165a27c9e Mon Sep 17 00:00:00 2001 From: benjamin-small Date: Mon, 7 Sep 2026 16:11:38 -0400 Subject: [PATCH] fix(fourier): build text outlines per glyph; add a real-font test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit font.getPath(text) runs opentype.js's shaping pass, which throws "lookupType: 6 … not yet supported" on Space Mono's GSUB chained contextual substitutions — so the Fourier lab silently traced nothing on the deployed site. textToPath now gathers each glyph's own outline (charToGlyph → glyph.getPath) advancing by width + kerning, which never touches GSUB and works for any font dropped into public/fonts. textToPath accepts an injected `font`, and the test suite now parses the bundled TTF from disk and traces three strings through the real pipeline, so this class of failure is caught in CI. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/package-lock.json | 18 +++++ web/package.json | 1 + .../lib/fourier/__tests__/textPath.test.ts | 80 +++++++++++-------- web/src/lib/fourier/textPath.ts | 45 ++++++++--- web/src/lib/test/fakeViz.ts | 4 +- 5 files changed, 99 insertions(+), 49 deletions(-) diff --git a/web/package-lock.json b/web/package-lock.json index de101da..6032ad1 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -15,6 +15,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/svelte": "^5.2.0", "@tsconfig/svelte": "^5.0.4", + "@types/node": "^20.19.43", "@types/opentype.js": "^1.3.10", "@vitest/coverage-v8": "^2.1.9", "@vitest/ui": "^2.1.0", @@ -1617,6 +1618,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, "node_modules/@types/opentype.js": { "version": "1.3.10", "resolved": "https://registry.npmjs.org/@types/opentype.js/-/opentype.js-1.3.10.tgz", @@ -3730,6 +3741,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, "node_modules/uuid": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", diff --git a/web/package.json b/web/package.json index 40fc816..0351c92 100644 --- a/web/package.json +++ b/web/package.json @@ -17,6 +17,7 @@ "@testing-library/jest-dom": "^6.5.0", "@testing-library/svelte": "^5.2.0", "@tsconfig/svelte": "^5.0.4", + "@types/node": "^20.19.43", "@types/opentype.js": "^1.3.10", "@vitest/coverage-v8": "^2.1.9", "@vitest/ui": "^2.1.0", diff --git a/web/src/lib/fourier/__tests__/textPath.test.ts b/web/src/lib/fourier/__tests__/textPath.test.ts index fa36101..55d1930 100644 --- a/web/src/lib/fourier/__tests__/textPath.test.ts +++ b/web/src/lib/fourier/__tests__/textPath.test.ts @@ -1,45 +1,57 @@ -import { describe, expect, it, vi } from 'vitest'; -import { loadFont } from '../font'; -import { textToPath } from '../textPath'; +import { describe, it, expect, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import * as opentype from 'opentype.js'; +import { textToPath, glyphOutlineCommands } from '../textPath'; +import type { PathCommand } from '../geometry'; -// A fake font whose getPath() returns a 100×100 square outline, so no font file is needed. -vi.mock('../font', () => ({ - loadFont: vi.fn(async () => ({ - getPath: () => ({ - commands: [ - { type: 'M', x: 0, y: 0 }, - { type: 'L', x: 100, y: 0 }, - { type: 'L', x: 100, y: 100 }, - { type: 'L', x: 0, y: 100 }, - { type: 'Z' }, - ], - }), - })), -})); +// A 2-glyph fake font: each glyph is a unit square outline, advance 1000 units/em. +const square = (x: number): PathCommand[] => [ + { type: 'M', x, y: 0 }, { type: 'L', x: x + 50, y: 0 }, { type: 'L', x: x + 50, y: 50 }, { type: 'L', x, y: 50 }, { type: 'Z' }, +]; +const fakeFont = { + unitsPerEm: 1000, + charToGlyph: () => ({ advanceWidth: 600, getPath: (x: number) => ({ commands: square(x) }) }), + getKerningValue: () => 0, +} as unknown as opentype.Font; -describe('textToPath', () => { - it('returns [] for blank text without loading the font', async () => { +vi.mock('../font', () => ({ loadFont: vi.fn(async () => fakeFont) })); + +describe('textToPath (fake font)', () => { + it('returns [] for blank text', async () => { expect(await textToPath('')).toEqual([]); expect(await textToPath(' ')).toEqual([]); - expect(loadFont).not.toHaveBeenCalled(); }); - - it('produces exactly `samples` finite, normalized, pen-tagged points', async () => { - const pts = await textToPath('x', { samples: 40 }); + it('produces the requested number of finite, pen-tagged samples', async () => { + const pts = await textToPath('xy', { samples: 40 }); expect(pts).toHaveLength(40); - for (const p of pts) { - expect(Number.isFinite(p.x)).toBe(true); - expect(Number.isFinite(p.y)).toBe(true); - expect(typeof p.pen).toBe('boolean'); - } + expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); expect(pts.some((p) => p.pen)).toBe(true); + expect(pts.some((p) => !p.pen)).toBe(true); // the hop between the two squares + }); + it('advances by glyph width so glyphs do not overlap', () => { + const cmds = glyphOutlineCommands(fakeFont, 'ab', 100); + const xs = cmds.flatMap((c) => (c.type === 'M' ? [c.x] : [])); + expect(xs).toEqual([0, 60]); // 600 units * (100 / 1000) + }); +}); + +describe('textToPath (real bundled font)', () => { + // Guards against opentype.js shaping limitations: font.getPath() throws on + // Space Mono's GSUB lookups, so we must build outlines per glyph. + // vitest runs from web/ (its config dir); import.meta.url is not a file: URL there. + const ttf = readFileSync(path.join(process.cwd(), 'public', 'fonts', 'SpaceMono-Regular.ttf')); + const font = opentype.parse(ttf.buffer.slice(ttf.byteOffset, ttf.byteOffset + ttf.byteLength)); - // Normalized: bbox centered at the origin with max extent 2. + it.each(['POIETIC TECH', 'poietic tech', 'Hello, world!'])('traces %j', async (text) => { + const pts = await textToPath(text, { font }); + expect(pts).toHaveLength(2000); + expect(pts.every((p) => Number.isFinite(p.x) && Number.isFinite(p.y))).toBe(true); const xs = pts.map((p) => p.x); - const ys = pts.map((p) => p.y); - expect(Math.min(...xs)).toBeCloseTo(-1, 9); - expect(Math.max(...xs)).toBeCloseTo(1, 9); - expect(Math.min(...ys)).toBeCloseTo(-1, 9); - expect(Math.max(...ys)).toBeCloseTo(1, 9); + expect(Math.max(...xs)).toBeCloseTo(1, 2); + expect(Math.min(...xs)).toBeCloseTo(-1, 2); + const down = pts.filter((p) => p.pen).length; + expect(down).toBeGreaterThan(1000); + expect(down).toBeLessThan(2000); // travel hops between glyphs are pen-up }); }); diff --git a/web/src/lib/fourier/textPath.ts b/web/src/lib/fourier/textPath.ts index 4715bda..9efbac6 100644 --- a/web/src/lib/fourier/textPath.ts +++ b/web/src/lib/fourier/textPath.ts @@ -1,28 +1,47 @@ +// Text → closed, pen-tagged path for the Fourier lab. +// +// Outlines are gathered PER GLYPH (charToGlyph → glyph.getPath) rather than via +// font.getPath(text): the latter runs opentype.js's shaping pass, which throws +// on GSUB lookups it doesn't implement (Space Mono's chained contextual +// substitutions, for one). We don't need ligatures to trace letters; kerning is +// applied from the kern/GPOS pair table so proportional fonts still look right. +import type * as opentype from 'opentype.js'; import { loadFont } from './font'; import { buildLoop, flattenCommands, normalize, resampleClosed, type PathCommand, type PathPoint } from './geometry'; export type TextToPathOptions = { - /** Number of output samples, uniformly spaced by arc length. */ + /** Total samples in the closed loop (uniform arc length). Default 2000. */ samples?: number; - /** Font size in font units used for layout; the result is normalized anyway. */ + /** Font size used for glyph outlines before normalization. Default 100. */ fontSize?: number; - /** Fixed subdivisions per Bézier segment when flattening glyph outlines. */ + /** Bézier subdivision steps in flattenCommands. Default 16. */ steps?: number; + /** Inject a parsed font (tests / tooling); defaults to the bundled font via loadFont(). */ + font?: opentype.Font; }; -/** - * Turn `text` into one closed, arc-length-uniform, pen-tagged loop in normalized y-up - * coordinates (bbox centered at the origin, `max(width, height) / 2 === 1`). - * Blank text, or text with no drawable outline, yields `[]`. - */ +/** Concatenated outline commands for `text`, advancing by each glyph's width (+ kerning). */ +export function glyphOutlineCommands(font: opentype.Font, text: string, fontSize: number): PathCommand[] { + const scale = fontSize / font.unitsPerEm; + const out: PathCommand[] = []; + let x = 0; + let prev: opentype.Glyph | null = null; + for (const ch of text) { + const glyph = font.charToGlyph(ch); + if (prev) x += font.getKerningValue(prev, glyph) * scale; + const path = glyph.getPath(x, 0, fontSize); + out.push(...(path.commands as PathCommand[])); + x += (glyph.advanceWidth ?? 0) * scale; + prev = glyph; + } + return out; +} + export async function textToPath(text: string, opts: TextToPathOptions = {}): Promise { const { samples = 2000, fontSize = 100, steps = 16 } = opts; if (!text.trim()) return []; - - const font = await loadFont(); - const cmds = font.getPath(text, 0, 0, fontSize).commands as PathCommand[]; - const contours = flattenCommands(cmds, steps); + const font = opts.font ?? (await loadFont()); + const contours = flattenCommands(glyphOutlineCommands(font, text, fontSize), steps); if (contours.length === 0) return []; - return normalize(resampleClosed(buildLoop(contours), samples)); } diff --git a/web/src/lib/test/fakeViz.ts b/web/src/lib/test/fakeViz.ts index bcff552..a59d0ab 100644 --- a/web/src/lib/test/fakeViz.ts +++ b/web/src/lib/test/fakeViz.ts @@ -7,8 +7,8 @@ import type { FourierSummary } from '../fourier/summary'; /** jsdom has no rAF; drive frame loops off setTimeout(0) so one frame runs per macrotask. */ export function installRafPolyfill() { globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => - setTimeout(() => cb(0), 0)) as typeof requestAnimationFrame; - globalThis.cancelAnimationFrame = ((id: number) => clearTimeout(id)) as typeof cancelAnimationFrame; + setTimeout(() => cb(0), 0)) as unknown as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = ((id: number) => clearTimeout(id)) as unknown as typeof cancelAnimationFrame; } /** Records every `Engine.free()` so tests can assert the shell releases the engine on teardown. */