# S03 — Text System + Font Loading — Research

**Date:** 2026-03-26

## Summary

This slice adds text objects to the Kerf design canvas with font loading, property editing, and text-to-path conversion using opentype.js. The work extends the existing discriminated union type system (D007/D008) by adding a `'text'` type, follows the established pattern of state management through `useCanvasState`, and renders text on the Konva canvas via `react-konva`'s `Text` component (for live editing) with conversion to `Path` components (for final output).

The core risk is opentype.js integration — specifically loading `.ttf`/`.otf` files from `app/public/fonts/`, parsing them into `Font` objects, and extracting accurate SVG path data via `Font.getPath().toPathData()`. The opentype.js API (v1.3.4) is well-documented and provides exactly the methods needed: `opentype.parse(arrayBuffer)` to load fonts, `Font.getPath(text, x, y, fontSize)` to generate path objects, and `Path.toPathData({flipY: true})` to produce SVG path `d` attributes compatible with Konva's `Path` component. This is a targeted research — the pattern is clear, the APIs are known, the extension points are explicit.

## Recommendation

**Approach:** Bundle 3-5 open-source fonts (e.g., Roboto, Open Sans, Lato, Oswald, Playfair Display — all Google Fonts with OFL license) in `app/public/fonts/`. Create a `fontService` utility that lazily loads and caches parsed `opentype.Font` objects. Add `TextObject` to the canvas type system. Render text on canvas using Konva's `Text` component for live editing, then use opentype.js `Font.getPath().toPathData()` to convert text to Konva `Path` elements when the user triggers "Convert to Paths". Build a `TextProperties` panel section in `ShapeProperties` for font family, size, letter spacing, and line height controls.

**Why:** This approach separates concerns cleanly — Konva `Text` handles display and editing (with system font rendering), while opentype.js handles the precision text-to-path conversion needed for downstream kerf-aware export. Bundled fonts guarantee availability without network dependencies. The font service pattern keeps parsing async and cached, avoiding repeated ArrayBuffer fetches.

## Implementation Landscape

### Key Files

- `app/src/types/canvas.ts` — Add `TextObject` interface and `'text'` to `CanvasObject` union. Fields: `text`, `fontFamily`, `fontSize`, `letterSpacing`, `lineHeight`, `fill`, `stroke`, `strokeWidth`, `width` (for text wrapping), `pathData` (optional — set after conversion to paths).
- `app/src/utils/fontService.ts` — **New.** Font loading and caching service. Exports `loadFont(familyName)`, `getAvailableFonts()`, `textToPathData(text, fontFamily, fontSize, letterSpacing)`. Uses `opentype.parse()` internally. Caches parsed Font objects in a Map.
- `app/src/hooks/useCanvasState.ts` — No changes needed to the hook itself — the existing `ADD_OBJECT`, `UPDATE_OBJECT`, `REMOVE_OBJECT` actions work with any `CanvasObject` union member. The `'text'` type flows through automatically.
- `app/src/components/canvas/KonvaStage.tsx` — Add `case 'text':` to `renderObject()` switch. Import `Text` from `react-konva`. Also add `case 'text':` to `getObjWidth()`/`getObjHeight()` helpers. Add `'text'` to `CanvasTool` union and tool creation in `handleStageMouseDown`.
- `app/src/components/canvas/CanvasToolbar.tsx` — Add text tool button (`T`) to TOOLS array.
- `app/src/components/canvas/ShapeProperties.tsx` — Add `case 'text':` handling with text-specific property controls (font picker dropdown, font size, letter spacing, line height). Add "Convert to Paths" button that calls `textToPathData()`.
- `app/src/components/canvas/ObjectPanel.tsx` — Add `text: 'T'` to `TYPE_ICONS` map.
- `app/public/fonts/` — **New directory.** Place bundled `.ttf` font files here. They'll be served by Vite's static file server at `/fonts/`.
- `app/src/utils/__tests__/fontService.test.ts` — **New.** Tests for font loading, caching, and text-to-path conversion.

### Build Order

1. **Font service utility** (`fontService.ts` + tests) — Prove that opentype.js loads fonts and extracts path data correctly. This is the riskiest piece — if path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's `Path`, everything downstream breaks. Test with a real bundled font file.
2. **Type system extension** (`canvas.ts`) — Add `TextObject` to discriminated union. Mechanical — just type definitions. Must happen before any component work.
3. **Canvas rendering + tool** (KonvaStage, CanvasToolbar, ObjectPanel) — Add text tool, `case 'text':` rendering via Konva `Text`, and tool icon. This wires text objects into the canvas without any property editing.
4. **Properties panel + convert-to-paths** (ShapeProperties) — Font picker, size/spacing controls, "Convert to Paths" button. The conversion replaces the `TextObject` with one or more `PathObject` entries (new type — a `Path` rendered via Konva `Path` from the `d` attribute). Or, more simply, it replaces the text object with an `ImageObject` using an SVG data URL built from the path data.

**Why this order:** Font service is the only unknown — validating opentype.js path extraction first de-risks everything. Types must exist before components compile. Rendering before properties follows the pattern established in S02.

### Verification Approach

1. **Unit tests:** `fontService.test.ts` — load a bundled font, call `textToPathData("Hello", "Roboto", 48, 0)`, assert the returned string starts with `M` and contains path commands. Test caching (second call doesn't re-fetch). Test error handling for missing font.
2. **Type safety:** `npx tsc --noEmit` — zero errors with the new `'text'` type added to all switch statements.
3. **Existing tests pass:** `npx vitest run` — all 71 existing tests still pass (no regressions).
4. **New component tests:** Test text object creation via tool, text property updates, convert-to-paths action.
5. **Full test suite:** `cd app && npx vitest run --reporter=verbose` — all tests pass.

## Don't Hand-Roll

| Problem | Existing Solution | Why Use It |
|---------|------------------|------------|
| Font file parsing + glyph extraction | `opentype.js` (v1.3.4) | Industry-standard OpenType parser for JavaScript. `Font.getPath()` → `Path.toPathData()` gives exact SVG path data for text. Has TypeScript types. Already identified in roadmap. |
| Text rendering on canvas | `react-konva` `Text` component | Already in the project. Handles text display, wrapping, and basic styling. Used for live editing before conversion. |
| Path rendering on canvas | `react-konva` `Path` component | Already imported in KonvaStage.tsx. Renders SVG path `d` attributes. Used for text-to-path conversion output. |

## Constraints

- **opentype.js loads fonts from ArrayBuffer** — must `fetch()` font files as ArrayBuffer, then call `opentype.parse()`. Cannot use CSS `@font-face` for path extraction — that only gives rendering, not glyph geometry.
- **Konva `Text` uses system/CSS fonts, not opentype.js** — for live canvas display, Konva renders text using the browser's text engine with `fontFamily` CSS. The opentype.js font and the Konva display font must match visually. This means the bundled fonts must also be loaded via `@font-face` CSS for Konva rendering.
- **Path.toPathData() needs `flipY: true`** — font coordinate systems have Y increasing upward, SVG/canvas have Y increasing downward. The `flipY` option handles this automatically.
- **TypeScript strict mode with `noUnusedLocals` and `noUnusedParameters`** — all new code must pass strict checks. The `erasableSyntaxOnly: true` flag means type-only imports must use `import type`.
- **`CanvasTool` type is defined in KonvaStage.tsx** — adding `'text'` requires updating the union type there, which affects CanvasToolbar's import.

## Common Pitfalls

- **Font not loaded for Konva rendering** — If the `@font-face` CSS isn't loaded before Konva renders the `Text` node, it falls back to a default font. Use `document.fonts.ready` or `FontFace.load()` API to ensure CSS font is loaded before rendering.
- **opentype.js `getPath()` y parameter is baseline, not top** — The `y` argument is the text baseline position, not the top of the text bounding box. To position text at a specific y coordinate, compute `y + font.ascender * (fontSize / font.unitsPerEm)`.
- **Letter spacing not built into `getPath()`** — opentype.js handles kerning automatically but has no letter-spacing parameter. Must implement letter spacing by calling `Font.charToGlyph()` per character and manually advancing the x position by `glyph.advanceWidth * (fontSize / unitsPerEm) + letterSpacing`.
- **Convert-to-paths replaces the text object** — After conversion, the text is no longer editable as text. This should be a destructive action with user confirmation.
- **Font file size** — Each `.ttf` file is 100-500KB. Bundling 3-5 fonts adds 0.5-2.5MB to public assets. This is acceptable for a design tool but shouldn't be imported into the JS bundle — serve from `public/`.

## Open Risks

- **Letter spacing implementation complexity** — If per-character path generation for letter spacing proves complex, it could be deferred to a follow-up, offering only kerning (built-in to opentype.js) initially.
- **Multi-line text path conversion** — `Font.getPath()` produces a single path for one line of text. Multi-line text requires splitting by line breaks and offsetting each line's y position by `lineHeight * fontSize`. This is straightforward but adds implementation surface.
- **Font licensing for bundled fonts** — Must use OFL (Open Font License) or Apache-licensed fonts only. Google Fonts catalog fonts (Roboto, Open Sans, etc.) are safe. Verify license files are included.
