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:
parent
0dcc96dee6
commit
ab170d8d20
8 changed files with 529 additions and 0 deletions
29
app/package-lock.json
generated
29
app/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
BIN
app/public/fonts/Lato-Regular.ttf
Normal file
BIN
app/public/fonts/Lato-Regular.ttf
Normal file
Binary file not shown.
BIN
app/public/fonts/OpenSans-Regular.ttf
Normal file
BIN
app/public/fonts/OpenSans-Regular.ttf
Normal file
Binary file not shown.
BIN
app/public/fonts/Roboto-Regular.ttf
Normal file
BIN
app/public/fonts/Roboto-Regular.ttf
Normal file
Binary file not shown.
|
|
@ -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 */
|
||||
|
|
|
|||
267
app/src/utils/__tests__/fontService.test.ts
Normal file
267
app/src/utils/__tests__/fontService.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
207
app/src/utils/fontService.ts
Normal file
207
app/src/utils/fontService.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue