test: Built fontService with opentype.js font loading, caching, text-to…

- "app/src/utils/fontService.ts"
- "app/src/utils/__tests__/fontService.test.ts"
- "app/public/fonts/Roboto-Regular.ttf"
- "app/public/fonts/OpenSans-Regular.ttf"
- "app/public/fonts/Lato-Regular.ttf"
- "app/src/App.css"
- "app/package.json"

GSD-Task: S03/T01
This commit is contained in:
jlightner 2026-03-26 05:53:04 +00:00
parent 0dcc96dee6
commit ab170d8d20
8 changed files with 529 additions and 0 deletions

29
app/package-lock.json generated
View file

@ -9,6 +9,7 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"konva": "^10.2.3", "konva": "^10.2.3",
"opentype.js": "^1.3.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-konva": "^19.2.3" "react-konva": "^19.2.3"
@ -3202,6 +3203,22 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/opentype.js": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/opentype.js/-/opentype.js-1.3.4.tgz",
"integrity": "sha512-d2JE9RP/6uagpQAVtJoF0pJJA/fgai89Cc50Yp0EJHk+eLp6QQ7gBoblsnubRULNY132I0J1QKMJ+JTbMqz4sw==",
"license": "MIT",
"dependencies": {
"string.prototype.codepointat": "^0.2.1",
"tiny-inflate": "^1.0.3"
},
"bin": {
"ot": "bin/ot"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/optionator": { "node_modules/optionator": {
"version": "0.9.4", "version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -3637,6 +3654,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/string.prototype.codepointat": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/string.prototype.codepointat/-/string.prototype.codepointat-0.2.1.tgz",
"integrity": "sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==",
"license": "MIT"
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@ -3683,6 +3706,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/tiny-inflate": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
"license": "MIT"
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

View file

@ -11,6 +11,7 @@
}, },
"dependencies": { "dependencies": {
"konva": "^10.2.3", "konva": "^10.2.3",
"opentype.js": "^1.3.4",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"react-konva": "^19.2.3" "react-konva": "^19.2.3"

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -1,3 +1,28 @@
/* ── Bundled font @font-face declarations ── */
@font-face {
font-family: 'Roboto';
src: url('/fonts/Roboto-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Open Sans';
src: url('/fonts/OpenSans-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Lato';
src: url('/fonts/Lato-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
font-display: swap;
}
/* ── Global component styles for Kerf Engine app ── */ /* ── Global component styles for Kerf Engine app ── */
/* File Upload Zone */ /* File Upload Zone */

View file

@ -0,0 +1,267 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import {
loadFont,
loadFontByFamily,
textToPathData,
getAvailableFonts,
clearFontCache,
isFontCached,
} from '../fontService';
/**
* Load a real .ttf file from disk and return it as an ArrayBuffer.
* This lets us mock fetch() with real font data so opentype.js
* produces genuine glyph paths, proving the integration end-to-end.
*/
function readFontFile(filename: string): ArrayBuffer {
const fontPath = path.resolve(__dirname, '../../../public/fonts', filename);
const buffer = fs.readFileSync(fontPath);
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
);
}
/** Create a mock fetch Response from an ArrayBuffer. */
function mockFetchResponse(arrayBuffer: ArrayBuffer): Response {
return {
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(arrayBuffer),
} as unknown as Response;
}
/** Create a mock fetch Response for an error. */
function mockFetchError(status: number): Response {
return {
ok: false,
status,
arrayBuffer: () => Promise.reject(new Error('should not be called')),
} as unknown as Response;
}
// Cache font buffers so we don't read from disk on every test
let latoBuffer: ArrayBuffer;
let robotoBuffer: ArrayBuffer;
let openSansBuffer: ArrayBuffer;
beforeEach(() => {
// Read font files once (lazy — subsequent tests reuse the reference)
if (!latoBuffer) latoBuffer = readFontFile('Lato-Regular.ttf');
if (!robotoBuffer) robotoBuffer = readFontFile('Roboto-Regular.ttf');
if (!openSansBuffer) openSansBuffer = readFontFile('OpenSans-Regular.ttf');
});
describe('fontService', () => {
beforeEach(() => {
clearFontCache();
// Default mock: route any /fonts/*.ttf request to the correct buffer
vi.stubGlobal(
'fetch',
vi.fn((url: string) => {
if (url.includes('Lato'))
return Promise.resolve(mockFetchResponse(latoBuffer));
if (url.includes('Roboto'))
return Promise.resolve(mockFetchResponse(robotoBuffer));
if (url.includes('OpenSans'))
return Promise.resolve(mockFetchResponse(openSansBuffer));
return Promise.resolve(mockFetchError(404));
}),
);
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── getAvailableFonts ──
describe('getAvailableFonts', () => {
it('returns at least 3 bundled fonts', () => {
const fonts = getAvailableFonts();
expect(fonts.length).toBeGreaterThanOrEqual(3);
});
it('each font has family and file properties', () => {
for (const f of getAvailableFonts()) {
expect(f.family).toBeTruthy();
expect(f.file).toMatch(/\.ttf$/);
}
});
it('includes Roboto, Open Sans, and Lato', () => {
const families = getAvailableFonts().map((f) => f.family);
expect(families).toContain('Roboto');
expect(families).toContain('Open Sans');
expect(families).toContain('Lato');
});
});
// ── loadFont ──
describe('loadFont', () => {
it('fetches and parses a font by URL', async () => {
const font = await loadFont('/fonts/Lato-Regular.ttf');
expect(font).toBeDefined();
expect(font.unitsPerEm).toBeGreaterThan(0);
});
it('caches the font after first load', async () => {
await loadFont('/fonts/Lato-Regular.ttf');
expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true);
// Second call should not trigger fetch again
await loadFont('/fonts/Lato-Regular.ttf');
expect(fetch).toHaveBeenCalledTimes(1);
});
it('deduplicates concurrent requests for the same URL', async () => {
const p1 = loadFont('/fonts/Lato-Regular.ttf');
const p2 = loadFont('/fonts/Lato-Regular.ttf');
const [f1, f2] = await Promise.all([p1, p2]);
expect(f1).toBe(f2);
expect(fetch).toHaveBeenCalledTimes(1);
});
it('throws on fetch failure', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve(mockFetchError(500))),
);
await expect(loadFont('/fonts/bad.ttf')).rejects.toThrow(
/Failed to fetch font/,
);
});
it('clears cache via clearFontCache', async () => {
await loadFont('/fonts/Lato-Regular.ttf');
expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(true);
clearFontCache();
expect(isFontCached('/fonts/Lato-Regular.ttf')).toBe(false);
});
});
// ── loadFontByFamily ──
describe('loadFontByFamily', () => {
it('loads Roboto by family name', async () => {
const font = await loadFontByFamily('Roboto');
expect(font).toBeDefined();
expect(font.unitsPerEm).toBe(2048);
});
it('is case-insensitive', async () => {
const font = await loadFontByFamily('roboto');
expect(font).toBeDefined();
});
it('falls back to first bundled font for unknown families', async () => {
const font = await loadFontByFamily('UnknownFont');
expect(font).toBeDefined();
// Should have loaded the first bundled font (Roboto)
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('Roboto'),
);
});
});
// ── textToPathData ──
describe('textToPathData', () => {
it('produces non-empty path data for simple text', async () => {
const result = await textToPathData('Hello', 'Lato', 24);
expect(result.pathData).toBeTruthy();
expect(result.pathData.length).toBeGreaterThan(10);
});
it('path data starts with M (moveto) command', async () => {
const result = await textToPathData('A', 'Lato', 24);
expect(result.pathData).toMatch(/^M/);
});
it('path data contains Z (closepath) commands', async () => {
const result = await textToPathData('O', 'Lato', 24);
expect(result.pathData).toContain('Z');
});
it('returns positive width and height', async () => {
const result = await textToPathData('Hello', 'Lato', 24);
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
});
it('width scales with text length', async () => {
const short = await textToPathData('Hi', 'Lato', 24);
const long = await textToPathData('Hello World', 'Lato', 24);
expect(long.width).toBeGreaterThan(short.width);
});
it('height scales with font size', async () => {
const small = await textToPathData('A', 'Lato', 12);
const large = await textToPathData('A', 'Lato', 48);
expect(large.height).toBeGreaterThan(small.height);
// Height should scale roughly linearly with fontSize
const ratio = large.height / small.height;
expect(ratio).toBeCloseTo(4, 0); // 48/12 = 4
});
it('letter spacing increases total width', async () => {
const normal = await textToPathData('Hello', 'Lato', 24, 0);
const spaced = await textToPathData('Hello', 'Lato', 24, 5);
// 4 inter-character gaps × 5px = 20px extra
expect(spaced.width).toBeGreaterThan(normal.width);
const diff = spaced.width - normal.width;
expect(diff).toBeCloseTo(20, 0);
});
it('negative letter spacing decreases total width', async () => {
const normal = await textToPathData('Hello', 'Lato', 24, 0);
const tight = await textToPathData('Hello', 'Lato', 24, -1);
expect(tight.width).toBeLessThan(normal.width);
});
it('produces valid path data for all bundled fonts', async () => {
for (const fontDesc of getAvailableFonts()) {
const result = await textToPathData('Test', fontDesc.family, 24);
expect(result.pathData).toBeTruthy();
expect(result.width).toBeGreaterThan(0);
expect(result.height).toBeGreaterThan(0);
}
});
it('Y coordinates are positive (canvas coordinate system)', async () => {
const result = await textToPathData('A', 'Lato', 48);
// Extract all Y values from M and L commands
const yValues = [...result.pathData.matchAll(/[ML][\d.-]+ ([\d.-]+)/g)].map(
(m) => parseFloat(m[1]),
);
// All Y values should be >= 0 (top-left origin, Y-down)
expect(yValues.length).toBeGreaterThan(0);
for (const y of yValues) {
expect(y).toBeGreaterThanOrEqual(0);
}
});
it('handles empty string gracefully', async () => {
const result = await textToPathData('', 'Lato', 24);
expect(result.pathData).toBe('');
expect(result.width).toBe(0);
expect(result.height).toBeGreaterThan(0); // height is font-metric based
});
it('handles space-only string', async () => {
const result = await textToPathData(' ', 'Lato', 24);
// Spaces produce no visible glyphs but advance the cursor
expect(result.width).toBeGreaterThan(0);
});
it('returns consistent results for repeated calls (caching)', async () => {
const r1 = await textToPathData('Same', 'Lato', 24);
const r2 = await textToPathData('Same', 'Lato', 24);
expect(r1.pathData).toBe(r2.pathData);
expect(r1.width).toBe(r2.width);
expect(r1.height).toBe(r2.height);
});
});
});

View file

@ -0,0 +1,207 @@
/**
* Font loading, caching, and text-to-path conversion service.
*
* Uses opentype.js to parse .ttf files fetched from the public /fonts/ directory.
* Provides text-to-SVG-path conversion for the "Convert to Paths" feature.
*/
// opentype.js ships without TS declarations — we declare the subset we use.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type OpentypeFont = any;
/** Metadata for a bundled font available to the user. */
export interface FontDescriptor {
/** Display name shown in the font picker */
family: string;
/** Filename relative to /fonts/ (e.g. "Roboto-Regular.ttf") */
file: string;
}
/** Result from text-to-path conversion. */
export interface TextPathResult {
/** SVG path `d` attribute data */
pathData: string;
/** Total advance width in canvas pixels */
width: number;
/** Line height in canvas pixels (ascender + descender) */
height: number;
}
/** Registry of bundled fonts. Add entries here when new fonts are added to public/fonts/. */
const BUNDLED_FONTS: FontDescriptor[] = [
{ family: 'Roboto', file: 'Roboto-Regular.ttf' },
{ family: 'Open Sans', file: 'OpenSans-Regular.ttf' },
{ family: 'Lato', file: 'Lato-Regular.ttf' },
];
/** In-memory cache of parsed Font objects keyed by font file path. */
const fontCache = new Map<string, OpentypeFont>();
/** In-flight fetch promises to deduplicate concurrent loads for the same font. */
const loadingPromises = new Map<string, Promise<OpentypeFont>>();
/**
* Return the list of bundled fonts available for selection.
*/
export function getAvailableFonts(): FontDescriptor[] {
return BUNDLED_FONTS;
}
/**
* Resolve a font family display name to the bundled font URL.
* Falls back to the first bundled font if the family isn't found.
*/
function resolveFontUrl(family: string): string {
const desc = BUNDLED_FONTS.find(
(f) => f.family.toLowerCase() === family.toLowerCase(),
);
const file = desc ? desc.file : BUNDLED_FONTS[0].file;
return `/fonts/${file}`;
}
/**
* Load and parse a font from a URL. Returns a cached instance if available.
* Concurrent calls for the same URL are deduplicated.
*/
export async function loadFont(fontUrl: string): Promise<OpentypeFont> {
// Return cached font if available
const cached = fontCache.get(fontUrl);
if (cached) return cached;
// Deduplicate in-flight requests
const inflight = loadingPromises.get(fontUrl);
if (inflight) return inflight;
const promise = (async () => {
const opentype = await import('opentype.js');
const response = await fetch(fontUrl);
if (!response.ok) {
throw new Error(`Failed to fetch font: ${fontUrl} (${response.status})`);
}
const arrayBuffer = await response.arrayBuffer();
const font = opentype.parse(arrayBuffer);
fontCache.set(fontUrl, font);
return font;
})();
loadingPromises.set(fontUrl, promise);
try {
const font = await promise;
return font;
} finally {
loadingPromises.delete(fontUrl);
}
}
/**
* Load a font by its family display name (e.g. "Roboto").
* Resolves the family name to the bundled font URL, then loads it.
*/
export async function loadFontByFamily(family: string): Promise<OpentypeFont> {
const url = resolveFontUrl(family);
return loadFont(url);
}
/**
* Convert text to SVG path data using opentype.js.
*
* Handles per-character glyph positioning with manual x-advance to support
* letter spacing (which opentype's `getPath()` does not natively support).
*
* The returned path data uses a coordinate system suitable for SVG/Konva:
* - origin at top-left of the text bounding box
* - Y increases downward (font coordinates are flipped from Y-up to Y-down)
*
* @param text The string to convert
* @param family Font family name (resolved to a bundled font)
* @param fontSize Font size in pixels
* @param letterSpacing Extra spacing between characters in pixels (default 0)
* @returns Path data, dimensions
*/
export async function textToPathData(
text: string,
family: string,
fontSize: number,
letterSpacing = 0,
): Promise<TextPathResult> {
const font = await loadFontByFamily(family);
const scale = fontSize / font.unitsPerEm;
// Collect per-character path commands with manual x-advance for letter spacing
let cursorX = 0;
const allCommands: Array<{ type: string; x?: number; y?: number; x1?: number; y1?: number; x2?: number; y2?: number }> = [];
for (let i = 0; i < text.length; i++) {
const glyph = font.charToGlyph(text[i]);
// getPath returns path at font units, positioned at (x, y) at the given fontSize
// y=0 is the baseline — ascenders go negative, descenders go positive in font coords
const glyphPath = glyph.getPath(cursorX / scale, 0, font.unitsPerEm);
for (const cmd of glyphPath.commands) {
allCommands.push(cmd);
}
// Advance cursor: glyph advance width scaled + letter spacing
const advance = (glyph.advanceWidth ?? 0) * scale;
cursorX += advance + letterSpacing;
}
// Compute metrics for the ascender offset
const ascender = font.ascender * scale;
// Build SVG path data string, flipping Y and offsetting by ascender
// Font coords: Y-up, baseline at 0, ascenders negative
// Canvas coords: Y-down, top-left origin
// Transform: canvas_y = ascender - font_y (mapped through scale)
let pathData = '';
for (const cmd of allCommands) {
const yFlip = (y: number) => ascender - y * scale;
const xScale = (x: number) => x * scale;
switch (cmd.type) {
case 'M':
pathData += `M${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`;
break;
case 'L':
pathData += `L${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`;
break;
case 'Q':
pathData += `Q${round(xScale(cmd.x1!))} ${round(yFlip(cmd.y1!))} ${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`;
break;
case 'C':
pathData += `C${round(xScale(cmd.x1!))} ${round(yFlip(cmd.y1!))} ${round(xScale(cmd.x2!))} ${round(yFlip(cmd.y2!))} ${round(xScale(cmd.x!))} ${round(yFlip(cmd.y!))}`;
break;
case 'Z':
pathData += 'Z';
break;
}
}
// Total dimensions
const descender = Math.abs(font.descender * scale);
const width = cursorX > 0 ? cursorX - letterSpacing : 0; // remove trailing spacing
const height = ascender + descender;
return { pathData, width: round(width), height: round(height) };
}
/** Round to 2 decimal places to keep path data compact. */
function round(n: number): number {
return Math.round(n * 100) / 100;
}
/**
* Clear the font cache. Useful for testing.
*/
export function clearFontCache(): void {
fontCache.clear();
loadingPromises.clear();
}
/**
* Check if a font is already cached by URL.
*/
export function isFontCached(fontUrl: string): boolean {
return fontCache.has(fontUrl);
}