diff --git a/app/package-lock.json b/app/package-lock.json index 27a8dc4..524a410 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "dependencies": { "konva": "^10.2.3", + "opentype.js": "^1.3.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3" @@ -3202,6 +3203,22 @@ ], "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": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -3637,6 +3654,12 @@ "dev": true, "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": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", @@ -3683,6 +3706,12 @@ "dev": true, "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": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/app/package.json b/app/package.json index 714381a..4a0fb4e 100644 --- a/app/package.json +++ b/app/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "konva": "^10.2.3", + "opentype.js": "^1.3.4", "react": "^19.2.4", "react-dom": "^19.2.4", "react-konva": "^19.2.3" diff --git a/app/public/fonts/Lato-Regular.ttf b/app/public/fonts/Lato-Regular.ttf new file mode 100644 index 0000000..0f3d0f8 Binary files /dev/null and b/app/public/fonts/Lato-Regular.ttf differ diff --git a/app/public/fonts/OpenSans-Regular.ttf b/app/public/fonts/OpenSans-Regular.ttf new file mode 100644 index 0000000..9db8569 Binary files /dev/null and b/app/public/fonts/OpenSans-Regular.ttf differ diff --git a/app/public/fonts/Roboto-Regular.ttf b/app/public/fonts/Roboto-Regular.ttf new file mode 100644 index 0000000..5522a36 Binary files /dev/null and b/app/public/fonts/Roboto-Regular.ttf differ diff --git a/app/src/App.css b/app/src/App.css index 2ab5cb6..747f858 100644 --- a/app/src/App.css +++ b/app/src/App.css @@ -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 ── */ /* File Upload Zone */ diff --git a/app/src/utils/__tests__/fontService.test.ts b/app/src/utils/__tests__/fontService.test.ts new file mode 100644 index 0000000..f3d807c --- /dev/null +++ b/app/src/utils/__tests__/fontService.test.ts @@ -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); + }); + }); +}); diff --git a/app/src/utils/fontService.ts b/app/src/utils/fontService.ts new file mode 100644 index 0000000..49f3b20 --- /dev/null +++ b/app/src/utils/fontService.ts @@ -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(); + +/** In-flight fetch promises to deduplicate concurrent loads for the same font. */ +const loadingPromises = new Map>(); + +/** + * 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 { + // 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 { + 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 { + 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); +}