Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
80 changes: 46 additions & 34 deletions web/src/lib/fourier/__tests__/textPath.test.ts
Original file line number Diff line number Diff line change
@@ -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
});
});
45 changes: 32 additions & 13 deletions web/src/lib/fourier/textPath.ts
Original file line number Diff line number Diff line change
@@ -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<PathPoint[]> {
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));
}
4 changes: 2 additions & 2 deletions web/src/lib/test/fakeViz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading