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
4215ef7b8c
commit
ff246b3d52
21 changed files with 1369 additions and 12 deletions
|
|
@ -12,3 +12,4 @@
|
||||||
| D004 | | architecture | Engine Docker image build strategy | Multi-stage build: builder stage compiles pypotrace with build-essential/libagg-dev/libpotrace-dev, runtime stage uses python:3.11-slim with only runtime libs (libpotrace0, libagg2, curl). Only engine source is copied — no App code. | Multi-stage keeps the runtime image small by excluding build tools. Copying only engine source enforces the Engine/App separation (D001). curl is included for Docker HEALTHCHECK. | Yes | agent |
|
| D004 | | architecture | Engine Docker image build strategy | Multi-stage build: builder stage compiles pypotrace with build-essential/libagg-dev/libpotrace-dev, runtime stage uses python:3.11-slim with only runtime libs (libpotrace0, libagg2, curl). Only engine source is copied — no App code. | Multi-stage keeps the runtime image small by excluding build tools. Copying only engine source enforces the Engine/App separation (D001). curl is included for Docker HEALTHCHECK. | Yes | agent |
|
||||||
| D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent |
|
| D005 | | architecture | Frontend technology stack for Kerf App | Vite + React + TypeScript with plain CSS modules, Vitest for testing, raw fetch + FormData for engine API calls, no UI framework for V1 | Minimal dependency surface for a tool that needs fast iteration. The engine API uses multipart/form-data which works naturally with native fetch + FormData. CSS modules avoid global style conflicts without adding a framework. Vitest integrates natively with Vite's transform pipeline. No UI library needed — the app has a focused interface with ~8 components. | Yes | agent |
|
||||||
| D006 | | frontend | React hook params stabilization strategy for useDebouncedTrace | JSON.stringify params in useEffect deps array, single-effect architecture | useCallback-based triggerTrace with object params caused infinite re-render loops because React creates new object references each render. JSON.stringify converts params to a stable string for dependency comparison, eliminating the need for callers to useMemo their params objects. Simpler API surface and no re-render bugs. | Yes | agent |
|
| D006 | | frontend | React hook params stabilization strategy for useDebouncedTrace | JSON.stringify params in useEffect deps array, single-effect architecture | useCallback-based triggerTrace with object params caused infinite re-render loops because React creates new object references each render. JSON.stringify converts params to a stable string for dependency comparison, eliminating the need for callers to useMemo their params objects. Simpler API surface and no re-render bugs. | Yes | agent |
|
||||||
|
| D007 | | architecture | Canvas object type system design | Discriminated union on `type` field: 'rect' \| 'circle' \| 'ellipse' \| 'line' \| 'image'. All objects share BaseCanvasObject (id, name, x, y, visible, locked, stroke, fill, opacity). Type-specific fields via intersection types. | TypeScript discriminated unions enable exhaustive type narrowing in switch/if statements, catching missing cases at compile time. Shared base keeps CRUD operations generic while type-specific panels (ShapeProperties) safely narrow to access unique fields. 'text' type will be added in S03. | Yes | agent |
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,6 @@
|
||||||
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T02"},"ts":"2026-03-26T05:36:12.635Z","actor":"agent","hash":"8dd660d191cc3758","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T02"},"ts":"2026-03-26T05:36:12.635Z","actor":"agent","hash":"8dd660d191cc3758","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T03"},"ts":"2026-03-26T05:40:11.226Z","actor":"agent","hash":"0db7c0c1fa2fd555","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T03"},"ts":"2026-03-26T05:40:11.226Z","actor":"agent","hash":"0db7c0c1fa2fd555","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T04"},"ts":"2026-03-26T05:41:35.200Z","actor":"agent","hash":"eacbb47f931ba2af","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S02","taskId":"T04"},"ts":"2026-03-26T05:41:35.200Z","actor":"agent","hash":"eacbb47f931ba2af","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
{"cmd":"complete-slice","params":{"milestoneId":"M002","sliceId":"S02"},"ts":"2026-03-26T05:44:01.083Z","actor":"agent","hash":"7c28ef1e308c7de7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
{"cmd":"plan-slice","params":{"milestoneId":"M002","sliceId":"S03"},"ts":"2026-03-26T05:48:40.020Z","actor":"agent","hash":"22ad4efa07f9be81","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
{"cmd":"complete-task","params":{"milestoneId":"M002","sliceId":"S03","taskId":"T01"},"ts":"2026-03-26T05:52:47.302Z","actor":"agent","hash":"c631fa7e62a3f1cc","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"}
|
||||||
|
|
|
||||||
|
|
@ -7,5 +7,5 @@ Build the React frontend with the Import & Convert view (View 1) and the Design
|
||||||
| ID | Slice | Risk | Depends | Done | After this |
|
| ID | Slice | Risk | Depends | Done | After this |
|
||||||
|----|-------|------|---------|------|------------|
|
|----|-------|------|---------|------|------------|
|
||||||
| S01 | Import & Convert UI (View 1) | medium — debounced preview updates, engine api integration from browser | — | ✅ | Upload a PNG, see live preview with preset selection and slider controls, click 'Use This' to advance to canvas |
|
| S01 | Import & Convert UI (View 1) | medium — debounced preview updates, engine api integration from browser | — | ✅ | Upload a PNG, see live preview with preset selection and slider controls, click 'Use This' to advance to canvas |
|
||||||
| S02 | Design Canvas Core (View 2) | medium — konva.js setup, selection handles, undo/redo state management | S01 | ⬜ | Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history |
|
| S02 | Design Canvas Core (View 2) | medium — konva.js setup, selection handles, undo/redo state management | S01 | ✅ | Create artboards in each shape, add/move/resize shapes, multi-select with alignment, undo/redo through history |
|
||||||
| S03 | Text System + Font Loading | medium — opentype.js integration, font loading from volume, path extraction accuracy | S02 | ⬜ | Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs |
|
| S03 | Text System + Font Loading | medium — opentype.js integration, font loading from volume, path extraction accuracy | S02 | ⬜ | Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs |
|
||||||
|
|
|
||||||
128
.gsd/milestones/M002/slices/S02/S02-SUMMARY.md
Normal file
128
.gsd/milestones/M002/slices/S02/S02-SUMMARY.md
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
---
|
||||||
|
id: S02
|
||||||
|
parent: M002
|
||||||
|
milestone: M002
|
||||||
|
provides:
|
||||||
|
- CanvasObject type system (types/canvas.ts) — discriminated union ready for 'text' type extension in S03
|
||||||
|
- useCanvasState hook with full CRUD, selection, undo/redo, reorder, visibility/lock toggle
|
||||||
|
- KonvaStage component rendering artboard + all object types + selection/transform
|
||||||
|
- DesignCanvas view container wired into App.tsx with View 1 → View 2 data flow
|
||||||
|
- Artboard shape utilities (shield, pennant paths, presets, unit conversion)
|
||||||
|
- Alignment/distribute utility functions
|
||||||
|
- Canvas panel system (ObjectPanel, AlignmentBar, CanvasToolbar, ShapeProperties)
|
||||||
|
requires:
|
||||||
|
[]
|
||||||
|
affects:
|
||||||
|
- S03
|
||||||
|
key_files:
|
||||||
|
- app/src/types/canvas.ts
|
||||||
|
- app/src/hooks/useCanvasState.ts
|
||||||
|
- app/src/hooks/__tests__/useCanvasState.test.ts
|
||||||
|
- app/src/utils/artboardShapes.ts
|
||||||
|
- app/src/utils/__tests__/artboardShapes.test.ts
|
||||||
|
- app/src/utils/alignment.ts
|
||||||
|
- app/src/utils/__tests__/alignment.test.ts
|
||||||
|
- app/src/components/canvas/ArtboardSetup.tsx
|
||||||
|
- app/src/components/canvas/KonvaStage.tsx
|
||||||
|
- app/src/components/canvas/ObjectPanel.tsx
|
||||||
|
- app/src/components/canvas/AlignmentBar.tsx
|
||||||
|
- app/src/components/canvas/CanvasToolbar.tsx
|
||||||
|
- app/src/components/canvas/ShapeProperties.tsx
|
||||||
|
- app/src/views/DesignCanvas.tsx
|
||||||
|
- app/src/views/DesignCanvas.module.css
|
||||||
|
- app/src/App.tsx
|
||||||
|
- app/src/App.css
|
||||||
|
key_decisions:
|
||||||
|
- D007: Canvas state uses useReducer + useRef pattern — reducer for renders, ref for undo/redo history. Select/deselect excluded from undo stack. History capped at 50.
|
||||||
|
- D008: Canvas objects use TypeScript discriminated union on type field (rect|circle|ellipse|line|image) with shared BaseCanvasObject.
|
||||||
|
- Artboard centered on stage via computed offsets; object positions stored relative to artboard origin
|
||||||
|
- SVG import from View 1 uses Blob URL → Image element → Konva.Image with auto-scale to fit artboard
|
||||||
|
- ObjectPanel displays objects in reverse z-order (frontmost at top) matching standard design tool UX
|
||||||
|
- Keyboard shortcuts use window-level keydown with activeElement tag guard to skip input/textarea/select
|
||||||
|
patterns_established:
|
||||||
|
- Discriminated union CanvasObject type system — add new object types by extending the union and adding case handling
|
||||||
|
- useCanvasState hook as single source of truth for all canvas mutations — all components dispatch through it
|
||||||
|
- Panel components receive state + dispatch callbacks from DesignCanvas — no direct hook usage in panels
|
||||||
|
- vitest-canvas-mock for Konva/canvas testing in Vitest (not jest-canvas-mock)
|
||||||
|
observability_surfaces:
|
||||||
|
- none
|
||||||
|
drill_down_paths:
|
||||||
|
- .gsd/milestones/M002/slices/S02/tasks/T01-SUMMARY.md
|
||||||
|
- .gsd/milestones/M002/slices/S02/tasks/T02-SUMMARY.md
|
||||||
|
- .gsd/milestones/M002/slices/S02/tasks/T03-SUMMARY.md
|
||||||
|
- .gsd/milestones/M002/slices/S02/tasks/T04-SUMMARY.md
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-03-26T05:44:01.051Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# S02: Design Canvas Core (View 2)
|
||||||
|
|
||||||
|
**Built the Konva.js-powered 2D design canvas with artboard setup, shape tools, selection/transform, layer management, alignment, properties editing, keyboard shortcuts, and undo/redo — 48 new tests (71 total), zero TypeScript errors.**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
This slice delivered the complete Design Canvas (View 2) for the Kerf Engine app across four tasks.
|
||||||
|
|
||||||
|
**T01 — Foundation:** Established the canvas type system (`types/canvas.ts`) with a discriminated union for five object types (rect, circle, ellipse, line, image) sharing a `BaseCanvasObject`. Built the central `useCanvasState` hook using a useReducer + useRef pattern — reducer drives re-renders for current state, ref holds undo/redo history stacks without triggering renders. Select/deselect are excluded from the undo stack (UI-only concern). History is capped at 50 entries. Created artboard shape utilities (shield/pennant SVG paths, dimension presets, unit conversion), 9 pure alignment/distribute functions, and the `ArtboardSetup` modal component. Installed konva, react-konva, and vitest-canvas-mock. 48 new tests passing.
|
||||||
|
|
||||||
|
**T02 — Rendering Layer:** Created `KonvaStage` rendering artboard backgrounds (Rect/Circle/Ellipse/Path based on shape config), mapping all canvas objects to Konva primitives with drag/transform handlers, Transformer synced to selected nodes, and rubber-band multi-select rectangle. Built `DesignCanvas` view container with ResizeObserver-driven sizing, artboard setup flow, and SVG import (Blob URL → Image element → Konva.Image with auto-scale). Wired into `App.tsx` to receive real svgData/traceMetadata props from View 1.
|
||||||
|
|
||||||
|
**T03 — Panel System:** Built four panel components: `ObjectPanel` (layer list in reverse z-order with drag reorder, visibility/lock toggles, rename), `AlignmentBar` (6 alignment + 2 distribute + center-on-artboard), `CanvasToolbar` (tool switcher, undo/redo, grid toggle, zoom controls), and `ShapeProperties` (stroke/fill/dimensions/line-style editing with discriminated union narrowing). All wired to useCanvasState in DesignCanvas.
|
||||||
|
|
||||||
|
**T04 — Shortcuts & Integration:** Added window-level keyboard shortcuts (Ctrl+Z undo, Ctrl+Shift+Z redo, Delete/Backspace remove, Escape deselect, Ctrl+A select all) with activeElement guard to skip input fields. Verified full integration: View 1 → Use This → Artboard Setup → Canvas with imported SVG. All 71 tests pass, zero TypeScript errors.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Ran `cd app && npx vitest run --reporter=verbose` — 71 tests pass across 6 test files, 0 failures (exit 0). Ran `cd app && npx tsc --noEmit` — zero type errors under strict mode with noUnusedLocals/noUnusedParameters (exit 0).
|
||||||
|
|
||||||
|
## Requirements Advanced
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Requirements Validated
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## New Requirements Surfaced
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Requirements Invalidated or Re-scoped
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
Used vitest-canvas-mock instead of jest-canvas-mock (plan specified jest-canvas-mock but it crashes in Vitest because it internally calls jest.fn()). This was identified in T01 and recorded as L010 in KNOWLEDGE.md.
|
||||||
|
|
||||||
|
## Known Limitations
|
||||||
|
|
||||||
|
Canvas components (KonvaStage, DesignCanvas panels) do not have dedicated unit tests — they were verified via TypeScript compilation and manual integration. Konva rendering in jsdom is limited since canvas operations are mocked. Drag-to-reorder in ObjectPanel uses simple index swapping, not full drag-and-drop library.
|
||||||
|
|
||||||
|
## Follow-ups
|
||||||
|
|
||||||
|
S03 will add 'text' type to the CanvasObject discriminated union and integrate opentype.js for font loading and text-to-path conversion. ShapeProperties panel will need a text-specific section. The canvas object type system (D008) was designed to accommodate this extension.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `app/src/types/canvas.ts` — New — discriminated union type system for canvas objects (rect, circle, ellipse, line, image), ArtboardConfig, CanvasState, and CanvasAction types
|
||||||
|
- `app/src/hooks/useCanvasState.ts` — New — central state management hook with useReducer+useRef for CRUD, selection, undo/redo, reorder, visibility/lock
|
||||||
|
- `app/src/hooks/__tests__/useCanvasState.test.ts` — New — 20 tests covering all useCanvasState operations including undo/redo edge cases
|
||||||
|
- `app/src/utils/artboardShapes.ts` — New — artboard shape presets, shield/pennant SVG path generators, toPx/fromPx unit conversion, artboardClipPath
|
||||||
|
- `app/src/utils/__tests__/artboardShapes.test.ts` — New — 15 tests for artboard presets, path generation, unit conversion, clip paths
|
||||||
|
- `app/src/utils/alignment.ts` — New — 9 pure alignment/distribute/center functions for canvas objects
|
||||||
|
- `app/src/utils/__tests__/alignment.test.ts` — New — 13 tests for alignment, distribution, and center-on-artboard
|
||||||
|
- `app/src/components/canvas/ArtboardSetup.tsx` — New — modal for artboard shape/size/unit selection on entering canvas view
|
||||||
|
- `app/src/components/canvas/KonvaStage.tsx` — New — Konva Stage+Layer rendering artboard, all object types, Transformer, rubber-band selection
|
||||||
|
- `app/src/components/canvas/ObjectPanel.tsx` — New — layer management panel with z-order list, reorder, visibility/lock toggles, rename
|
||||||
|
- `app/src/components/canvas/AlignmentBar.tsx` — New — alignment and distribution toolbar consuming alignment utils
|
||||||
|
- `app/src/components/canvas/CanvasToolbar.tsx` — New — tool switcher, undo/redo, grid toggle, zoom controls
|
||||||
|
- `app/src/components/canvas/ShapeProperties.tsx` — New — property editor for selected shape (stroke, fill, dimensions, line style, opacity)
|
||||||
|
- `app/src/views/DesignCanvas.tsx` — New — View 2 container wiring KonvaStage + all panels to useCanvasState, SVG import, keyboard shortcuts
|
||||||
|
- `app/src/views/DesignCanvas.module.css` — New — CSS module for DesignCanvas layout
|
||||||
|
- `app/src/App.tsx` — Modified — wired DesignCanvas with svgData/traceMetadata props, removed underscore prefixes
|
||||||
|
- `app/src/App.css` — Modified — added comprehensive canvas UI styles (toolbar, panels, artboard setup, shape properties, alignment bar)
|
||||||
|
- `app/src/test-setup.ts` — Modified — added vitest-canvas-mock import for Konva testing
|
||||||
|
- `app/package.json` — Modified — added konva, react-konva, vitest-canvas-mock dependencies
|
||||||
122
.gsd/milestones/M002/slices/S02/S02-UAT.md
Normal file
122
.gsd/milestones/M002/slices/S02/S02-UAT.md
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
# S02: Design Canvas Core (View 2) — UAT
|
||||||
|
|
||||||
|
**Milestone:** M002
|
||||||
|
**Written:** 2026-03-26T05:44:01.052Z
|
||||||
|
|
||||||
|
## UAT: Design Canvas Core (View 2)
|
||||||
|
|
||||||
|
### Preconditions
|
||||||
|
- Engine API running at localhost:8000
|
||||||
|
- App dev server running (`cd app && npm run dev`)
|
||||||
|
- A raster image (PNG/JPG) available for upload
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test 1: View 1 → View 2 Transition
|
||||||
|
1. Upload a raster image in View 1
|
||||||
|
2. Wait for trace preview to render
|
||||||
|
3. Click "Use This" button
|
||||||
|
4. **Expected:** Artboard Setup modal appears with shape picker, dimension inputs, and unit toggle
|
||||||
|
|
||||||
|
### Test 2: Artboard Setup — Shape Selection
|
||||||
|
1. In Artboard Setup modal, click each shape option: rect, square, circle, oval, shield, pennant
|
||||||
|
2. **Expected:** Shape preview updates for each selection. Square and circle auto-set equal width/height. Width/height inputs update with preset dimensions.
|
||||||
|
3. Toggle units between inches and mm
|
||||||
|
4. **Expected:** Dimension values convert correctly (e.g., 3in → 76.2mm)
|
||||||
|
5. Click "Create Artboard"
|
||||||
|
6. **Expected:** Modal closes, Konva canvas renders with artboard background matching chosen shape
|
||||||
|
|
||||||
|
### Test 3: Shape Creation Tools
|
||||||
|
1. Click Rectangle tool in toolbar
|
||||||
|
2. Click on the canvas
|
||||||
|
3. **Expected:** A rectangle appears at the click position with default dimensions, black stroke, no fill
|
||||||
|
4. Repeat with Circle tool and Ellipse tool
|
||||||
|
5. **Expected:** Each shape appears correctly with default styling
|
||||||
|
6. Click Line tool and click on canvas
|
||||||
|
7. **Expected:** A horizontal line appears at the click position
|
||||||
|
|
||||||
|
### Test 4: Object Selection & Transform
|
||||||
|
1. Click on a shape on the canvas
|
||||||
|
2. **Expected:** Selection handles (Transformer) appear around the shape. ObjectPanel highlights the selected row.
|
||||||
|
3. Drag the shape to a new position
|
||||||
|
4. **Expected:** Shape moves with cursor. Position updates in state.
|
||||||
|
5. Drag a transform handle to resize
|
||||||
|
6. **Expected:** Shape resizes. Width/height update (scaleX/scaleY reset to 1, dimensions multiplied).
|
||||||
|
7. Click empty canvas area
|
||||||
|
8. **Expected:** Shape deselected. Transformer hidden. ObjectPanel row unhighlighted.
|
||||||
|
|
||||||
|
### Test 5: Multi-Select with Rubber Band
|
||||||
|
1. Create 3+ shapes on canvas
|
||||||
|
2. Click and drag on empty canvas area to draw a rubber-band rectangle over multiple shapes
|
||||||
|
3. **Expected:** All shapes intersecting the rectangle are selected. Transformer wraps all selected nodes. Multiple rows highlighted in ObjectPanel.
|
||||||
|
4. Shift-click an unselected shape
|
||||||
|
5. **Expected:** Shape added to selection without deselecting others.
|
||||||
|
|
||||||
|
### Test 6: ObjectPanel — Layer Management
|
||||||
|
1. Create 3 shapes (they appear as layers in ObjectPanel in reverse z-order — newest on top)
|
||||||
|
2. **Expected:** ObjectPanel lists all 3 shapes with type icons and default names
|
||||||
|
3. Double-click a shape name in ObjectPanel
|
||||||
|
4. **Expected:** Name becomes editable. Type new name, press Enter — name persists.
|
||||||
|
5. Click the eye icon on a shape row
|
||||||
|
6. **Expected:** Shape becomes invisible on canvas. Eye icon toggles to "hidden" state.
|
||||||
|
7. Click the lock icon on a shape row
|
||||||
|
8. **Expected:** Shape can no longer be dragged or resized on canvas.
|
||||||
|
|
||||||
|
### Test 7: Alignment Tools
|
||||||
|
1. Select 2+ shapes
|
||||||
|
2. Click "Align Left" in AlignmentBar
|
||||||
|
3. **Expected:** All selected shapes move so their left edges align with the leftmost shape's left edge
|
||||||
|
4. Click "Distribute Horizontally" (requires 3+ shapes selected)
|
||||||
|
5. **Expected:** Shapes evenly space horizontally
|
||||||
|
6. Select 1 shape, click "Center on Artboard"
|
||||||
|
7. **Expected:** Shape centers within the artboard bounds
|
||||||
|
|
||||||
|
### Test 8: Shape Properties Panel
|
||||||
|
1. Select a single rectangle
|
||||||
|
2. **Expected:** ShapeProperties panel appears showing stroke color, stroke weight, fill color, width, height, opacity
|
||||||
|
3. Change stroke color using color input
|
||||||
|
4. **Expected:** Shape stroke updates immediately on canvas
|
||||||
|
5. Change fill color and enable fill toggle
|
||||||
|
6. **Expected:** Shape fill updates on canvas
|
||||||
|
7. Select a line object
|
||||||
|
8. **Expected:** ShapeProperties shows line style dropdown (solid, dashed, dotted). Changing style updates the line's dash array.
|
||||||
|
|
||||||
|
### Test 9: Undo/Redo
|
||||||
|
1. Create a shape, move it, change its color (3 mutations)
|
||||||
|
2. Press Ctrl+Z three times
|
||||||
|
3. **Expected:** Each undo reverses one mutation in order: color reverts → position reverts → shape removed
|
||||||
|
4. Press Ctrl+Shift+Z (or Ctrl+Y) twice
|
||||||
|
5. **Expected:** Shape reappears, position restored
|
||||||
|
6. Click undo/redo buttons in CanvasToolbar
|
||||||
|
7. **Expected:** Same behavior as keyboard shortcuts. Buttons disabled when respective stack is empty.
|
||||||
|
|
||||||
|
### Test 10: Keyboard Shortcuts
|
||||||
|
1. Select a shape, press Delete (or Backspace)
|
||||||
|
2. **Expected:** Shape removed from canvas and ObjectPanel
|
||||||
|
3. Create multiple shapes, press Ctrl+A
|
||||||
|
4. **Expected:** All shapes selected
|
||||||
|
5. Press Escape
|
||||||
|
6. **Expected:** All shapes deselected
|
||||||
|
7. Focus a text input (e.g., shape name rename in ObjectPanel), press Delete
|
||||||
|
8. **Expected:** Shortcut does NOT fire — normal text editing behavior occurs
|
||||||
|
|
||||||
|
### Test 11: SVG Import from View 1
|
||||||
|
1. Upload an image in View 1, complete tracing, click "Use This"
|
||||||
|
2. Set up artboard, confirm
|
||||||
|
3. **Expected:** Traced SVG appears as an image object on the canvas, auto-scaled to fit within artboard bounds. Object appears in ObjectPanel as an image type.
|
||||||
|
|
||||||
|
### Test 12: Canvas Zoom & Grid
|
||||||
|
1. Click zoom-in button in CanvasToolbar
|
||||||
|
2. **Expected:** Canvas content appears larger
|
||||||
|
3. Click zoom-out button
|
||||||
|
4. **Expected:** Canvas content appears smaller
|
||||||
|
5. Click "Fit" button
|
||||||
|
6. **Expected:** Artboard fits within visible canvas area
|
||||||
|
7. Toggle grid button
|
||||||
|
8. **Expected:** Grid overlay toggles on/off on canvas
|
||||||
|
|
||||||
|
### Edge Cases
|
||||||
|
- **Empty undo:** Pressing Ctrl+Z with no history should do nothing (no crash)
|
||||||
|
- **Delete with nothing selected:** Pressing Delete with no selection should do nothing
|
||||||
|
- **Rapid undo/redo:** Rapidly alternating Ctrl+Z and Ctrl+Shift+Z should not corrupt state
|
||||||
|
- **Window resize:** Canvas should resize responsively via ResizeObserver without layout breaks
|
||||||
30
.gsd/milestones/M002/slices/S02/tasks/T04-VERIFY.json
Normal file
30
.gsd/milestones/M002/slices/S02/tasks/T04-VERIFY.json
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"taskId": "T04",
|
||||||
|
"unitId": "M002/S02/T04",
|
||||||
|
"timestamp": 1774503701418,
|
||||||
|
"passed": false,
|
||||||
|
"discoverySource": "task-plan",
|
||||||
|
"checks": [
|
||||||
|
{
|
||||||
|
"command": "cd app",
|
||||||
|
"exitCode": 0,
|
||||||
|
"durationMs": 4,
|
||||||
|
"verdict": "pass"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npx vitest run --reporter=verbose",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 1370,
|
||||||
|
"verdict": "fail"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"command": "npx tsc --noEmit",
|
||||||
|
"exitCode": 1,
|
||||||
|
"durationMs": 730,
|
||||||
|
"verdict": "fail"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"retryAttempt": 1,
|
||||||
|
"maxRetries": 2
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,67 @@
|
||||||
# S03: Text System + Font Loading
|
# S03: Text System + Font Loading
|
||||||
|
|
||||||
**Goal:** Add text objects with opentype.js font rendering, font picker from fonts.json manifest, text-to-paths conversion
|
**Goal:** Add text objects to the design canvas with font loading from bundled fonts, property editing (font family, size, letter spacing, line height), and text-to-path conversion using opentype.js.
|
||||||
**Demo:** After this: Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs
|
**Demo:** After this: Add text objects, select fonts from picker, change size/spacing, convert text to paths — paths match original glyphs
|
||||||
|
|
||||||
## Tasks
|
## Tasks
|
||||||
|
- [x] **T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests** — Create the fontService utility that loads .ttf fonts via fetch + opentype.parse(), caches parsed Font objects, and converts text to SVG path data. Bundle 3-5 OFL-licensed Google Fonts as .ttf files in app/public/fonts/. Add @font-face CSS declarations in App.css so Konva Text rendering matches the opentype.js fonts. Install opentype.js dependency. Write comprehensive unit tests for font loading, caching, and text-to-path conversion.
|
||||||
|
|
||||||
|
This is the riskiest piece of the slice — if opentype.js path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's Path component, everything downstream breaks. Testing with real bundled font files proves the integration.
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
- opentype.js loads fonts from ArrayBuffer — must fetch() font files then call opentype.parse()
|
||||||
|
- Path.toPathData() needs consideration of coordinate systems (font Y-up vs canvas Y-down)
|
||||||
|
- Letter spacing is NOT built into getPath() — must implement per-character glyph positioning with manual x-advance
|
||||||
|
- Font files served from /fonts/ via Vite's public directory
|
||||||
|
- @font-face CSS must load before Konva renders Text nodes
|
||||||
|
- TypeScript strict mode with verbatimModuleSyntax — use `import type` for type-only imports
|
||||||
|
- Tests should mock fetch() to return real font file ArrayBuffers loaded from disk
|
||||||
|
- Estimate: 1h
|
||||||
|
- Files: app/src/utils/fontService.ts, app/src/utils/__tests__/fontService.test.ts, app/public/fonts/, app/src/App.css, app/package.json
|
||||||
|
- Verify: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l
|
||||||
|
- [ ] **T02: Add TextObject type and wire text tool into canvas rendering** — Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types.
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping)
|
||||||
|
- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar
|
||||||
|
- KonvaStage renderObject switch: case 'text' renders <Text> from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props
|
||||||
|
- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2)
|
||||||
|
- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6
|
||||||
|
- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight
|
||||||
|
- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width)
|
||||||
|
- ObjectPanel TYPE_ICONS: text → 'T'
|
||||||
|
- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight
|
||||||
|
- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this
|
||||||
|
- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type
|
||||||
|
- Estimate: 1h
|
||||||
|
- Files: app/src/types/canvas.ts, app/src/components/canvas/KonvaStage.tsx, app/src/components/canvas/CanvasToolbar.tsx, app/src/components/canvas/ObjectPanel.tsx, app/src/components/canvas/ShapeProperties.tsx
|
||||||
|
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
|
||||||
|
- [ ] **T03: Build text properties panel and convert-to-paths action in ShapeProperties** — Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().
|
||||||
|
|
||||||
|
## Text Property Controls (in ShapeProperties)
|
||||||
|
When the selected object has type 'text', show:
|
||||||
|
1. **Text content** — textarea input bound to obj.text
|
||||||
|
2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily
|
||||||
|
3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize
|
||||||
|
4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing
|
||||||
|
5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight
|
||||||
|
6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill
|
||||||
|
7. **Stroke color + weight** — reuse existing stroke controls
|
||||||
|
8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler)
|
||||||
|
|
||||||
|
## Convert to Paths Implementation
|
||||||
|
- Show confirmation dialog (window.confirm) since this is destructive
|
||||||
|
- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService
|
||||||
|
- Create SVG string from returned path data: `<svg xmlns=... viewBox=...><path d="{pathData}" fill="{obj.fill}" stroke="{obj.stroke}" stroke-width="{obj.strokeWidth}"/></svg>`
|
||||||
|
- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position
|
||||||
|
- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop)
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData()
|
||||||
|
- The text property controls should only show when object.type === 'text'
|
||||||
|
- Existing stroke/fill/opacity/rotation controls still show for text objects
|
||||||
|
- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop
|
||||||
|
- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties
|
||||||
|
- Estimate: 1h
|
||||||
|
- Files: app/src/components/canvas/ShapeProperties.tsx, app/src/views/DesignCanvas.tsx
|
||||||
|
- Verify: cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
|
||||||
|
|
|
||||||
76
.gsd/milestones/M002/slices/S03/S03-RESEARCH.md
Normal file
76
.gsd/milestones/M002/slices/S03/S03-RESEARCH.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# 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.
|
||||||
39
.gsd/milestones/M002/slices/S03/tasks/T01-PLAN.md
Normal file
39
.gsd/milestones/M002/slices/S03/tasks/T01-PLAN.md
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 10
|
||||||
|
estimated_files: 5
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Build font service with opentype.js, bundle fonts, and add unit tests
|
||||||
|
|
||||||
|
Create the fontService utility that loads .ttf fonts via fetch + opentype.parse(), caches parsed Font objects, and converts text to SVG path data. Bundle 3-5 OFL-licensed Google Fonts as .ttf files in app/public/fonts/. Add @font-face CSS declarations in App.css so Konva Text rendering matches the opentype.js fonts. Install opentype.js dependency. Write comprehensive unit tests for font loading, caching, and text-to-path conversion.
|
||||||
|
|
||||||
|
This is the riskiest piece of the slice — if opentype.js path extraction doesn't produce accurate SVG `d` attributes compatible with Konva's Path component, everything downstream breaks. Testing with real bundled font files proves the integration.
|
||||||
|
|
||||||
|
## Key Constraints
|
||||||
|
- opentype.js loads fonts from ArrayBuffer — must fetch() font files then call opentype.parse()
|
||||||
|
- Path.toPathData() needs consideration of coordinate systems (font Y-up vs canvas Y-down)
|
||||||
|
- Letter spacing is NOT built into getPath() — must implement per-character glyph positioning with manual x-advance
|
||||||
|
- Font files served from /fonts/ via Vite's public directory
|
||||||
|
- @font-face CSS must load before Konva renders Text nodes
|
||||||
|
- TypeScript strict mode with verbatimModuleSyntax — use `import type` for type-only imports
|
||||||
|
- Tests should mock fetch() to return real font file ArrayBuffers loaded from disk
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- ``app/package.json` — add opentype.js dependency`
|
||||||
|
- ``app/src/App.css` — existing global styles, add @font-face declarations`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- ``app/src/utils/fontService.ts` — font loading, caching, text-to-path conversion service`
|
||||||
|
- ``app/src/utils/__tests__/fontService.test.ts` — unit tests for fontService`
|
||||||
|
- ``app/public/fonts/Roboto-Regular.ttf` — bundled font file`
|
||||||
|
- ``app/public/fonts/OpenSans-Regular.ttf` — bundled font file`
|
||||||
|
- ``app/public/fonts/Lato-Regular.ttf` — bundled font file`
|
||||||
|
- ``app/package.json` — updated with opentype.js dependency`
|
||||||
|
- ``app/src/App.css` — updated with @font-face declarations`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l
|
||||||
91
.gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md
Normal file
91
.gsd/milestones/M002/slices/S03/tasks/T01-SUMMARY.md
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S03
|
||||||
|
milestone: M002
|
||||||
|
provides: []
|
||||||
|
requires: []
|
||||||
|
affects: []
|
||||||
|
key_files: ["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"]
|
||||||
|
key_decisions: ["Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists)", "Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction", "Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates", "Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support"]
|
||||||
|
patterns_established: []
|
||||||
|
drill_down_paths: []
|
||||||
|
observability_surfaces: []
|
||||||
|
duration: ""
|
||||||
|
verification_result: "Ran: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/."
|
||||||
|
completed_at: 2026-03-26T05:52:47.243Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests
|
||||||
|
|
||||||
|
> Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
---
|
||||||
|
id: T01
|
||||||
|
parent: S03
|
||||||
|
milestone: M002
|
||||||
|
key_files:
|
||||||
|
- 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
|
||||||
|
key_decisions:
|
||||||
|
- Used opentype.js v1.3.4 with dynamic import() and local type declarations (no @types package exists)
|
||||||
|
- Variable font .ttf files for Roboto/OpenSans validated to work with opentype.js glyph extraction
|
||||||
|
- Y-axis flip (canvas_y = ascender - font_y * scale) for canvas-compatible SVG path coordinates
|
||||||
|
- Manual per-character glyph positioning for letter spacing since opentype.js getPath() lacks native support
|
||||||
|
duration: ""
|
||||||
|
verification_result: passed
|
||||||
|
completed_at: 2026-03-26T05:52:47.258Z
|
||||||
|
blocker_discovered: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# T01: Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests
|
||||||
|
|
||||||
|
**Built fontService with opentype.js font loading, caching, text-to-path conversion, bundled 3 OFL Google Fonts, and added 24 passing unit tests**
|
||||||
|
|
||||||
|
## What Happened
|
||||||
|
|
||||||
|
Installed opentype.js v1.3.4 as a production dependency. Downloaded three OFL-licensed Google Fonts (Roboto, Open Sans, Lato) as .ttf files into app/public/fonts/. Created app/src/utils/fontService.ts with loadFont, loadFontByFamily, textToPathData, getAvailableFonts, clearFontCache, and isFontCached exports. The service fetches .ttf files, parses them with opentype.js, caches parsed Font objects with in-flight deduplication, and converts text to SVG path data with manual per-character glyph positioning for letter spacing and Y-axis coordinate flip for canvas compatibility. Added @font-face declarations in App.css for all three fonts. Wrote 24 comprehensive unit tests covering font registry, loading/caching/deduplication/errors, family resolution, and text-to-path conversion with dimension scaling, letter spacing arithmetic, coordinate system validation, and edge cases.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Ran: cd app && npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose && npx tsc --noEmit && ls public/fonts/*.ttf | wc -l. All 24 tests pass, TypeScript compiles cleanly under strict mode, 3 font files confirmed in public/fonts/.
|
||||||
|
|
||||||
|
## Verification Evidence
|
||||||
|
|
||||||
|
| # | Command | Exit Code | Verdict | Duration |
|
||||||
|
|---|---------|-----------|---------|----------|
|
||||||
|
| 1 | `npx vitest run src/utils/__tests__/fontService.test.ts --reporter=verbose` | 0 | ✅ pass | 1050ms |
|
||||||
|
| 2 | `npx tsc --noEmit` | 0 | ✅ pass | 2200ms |
|
||||||
|
| 3 | `ls public/fonts/*.ttf | wc -l` | 0 | ✅ pass | 100ms |
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
|
||||||
|
None. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None.
|
||||||
|
|
||||||
|
## Files Created/Modified
|
||||||
|
|
||||||
|
- `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`
|
||||||
|
|
||||||
|
|
||||||
|
## Deviations
|
||||||
|
None. Variable font files used for Roboto and Open Sans instead of static weights — validated they work correctly with opentype.js.
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None.
|
||||||
42
.gsd/milestones/M002/slices/S03/tasks/T02-PLAN.md
Normal file
42
.gsd/milestones/M002/slices/S03/tasks/T02-PLAN.md
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 13
|
||||||
|
estimated_files: 5
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T02: Add TextObject type and wire text tool into canvas rendering
|
||||||
|
|
||||||
|
Extend the CanvasObject discriminated union with a TextObject type. Wire text objects into all canvas components: KonvaStage (render via Konva Text + case in getObjWidth/getObjHeight + text tool creation in handleStageMouseDown), CanvasToolbar (add 'text' to CanvasTool union and TOOLS array), ObjectPanel (add 'text' icon to TYPE_ICONS), and DesignCanvas (pass through). This follows the exact pattern established in S02 for adding object types.
|
||||||
|
|
||||||
|
## Key Implementation Details
|
||||||
|
- TextObject interface: extends BaseCanvasObject with type: 'text', text: string, fontFamily: string, fontSize: number, letterSpacing: number, lineHeight: number, fill: string, stroke: string, strokeWidth: number, width: number (for wrapping)
|
||||||
|
- CanvasTool union in KonvaStage.tsx must add 'text' — this is imported by CanvasToolbar
|
||||||
|
- KonvaStage renderObject switch: case 'text' renders <Text> from react-konva with fontFamily, fontSize, fill, stroke, strokeWidth, width, letterSpacing, lineHeight props
|
||||||
|
- KonvaStage handleStageMouseDown switch: case 'text' creates a TextObject with defaults (text: 'Text', fontFamily: 'Roboto', fontSize: 24, letterSpacing: 0, lineHeight: 1.2)
|
||||||
|
- KonvaStage getObjWidth for text: use obj.width (wrapping width) or estimate from fontSize * text.length * 0.6
|
||||||
|
- KonvaStage getObjHeight for text: obj.fontSize * obj.lineHeight
|
||||||
|
- KonvaStage onTransformEnd for text: scale width, keep fontSize unchanged (text wraps to new width)
|
||||||
|
- ObjectPanel TYPE_ICONS: text → 'T'
|
||||||
|
- ShapeProperties getWidth/getHeight: add case 'text' returning obj.width and obj.fontSize * obj.lineHeight
|
||||||
|
- All switch statements must be exhaustive — TypeScript noFallthroughCasesInSwitch enforces this
|
||||||
|
- TypeScript strict mode — no unused locals/params, erasableSyntaxOnly, use import type
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- ``app/src/types/canvas.ts` — existing CanvasObject discriminated union to extend`
|
||||||
|
- ``app/src/components/canvas/KonvaStage.tsx` — existing renderObject/getObjWidth/getObjHeight/handleStageMouseDown to extend`
|
||||||
|
- ``app/src/components/canvas/CanvasToolbar.tsx` — existing TOOLS array and CanvasTool import`
|
||||||
|
- ``app/src/components/canvas/ObjectPanel.tsx` — existing TYPE_ICONS map`
|
||||||
|
- ``app/src/components/canvas/ShapeProperties.tsx` — existing getWidth/getHeight helpers`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- ``app/src/types/canvas.ts` — updated with TextObject interface and 'text' in CanvasObject union`
|
||||||
|
- ``app/src/components/canvas/KonvaStage.tsx` — updated with text rendering, text tool creation, 'text' in CanvasTool union, text cases in getObjWidth/getObjHeight`
|
||||||
|
- ``app/src/components/canvas/CanvasToolbar.tsx` — updated with text tool button in TOOLS array`
|
||||||
|
- ``app/src/components/canvas/ObjectPanel.tsx` — updated with text icon in TYPE_ICONS`
|
||||||
|
- ``app/src/components/canvas/ShapeProperties.tsx` — updated with text cases in getWidth/getHeight`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
|
||||||
50
.gsd/milestones/M002/slices/S03/tasks/T03-PLAN.md
Normal file
50
.gsd/milestones/M002/slices/S03/tasks/T03-PLAN.md
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
---
|
||||||
|
estimated_steps: 23
|
||||||
|
estimated_files: 2
|
||||||
|
skills_used: []
|
||||||
|
---
|
||||||
|
|
||||||
|
# T03: Build text properties panel and convert-to-paths action in ShapeProperties
|
||||||
|
|
||||||
|
Add text-specific property controls to ShapeProperties and implement the 'Convert to Paths' action that replaces a TextObject with path-based objects using fontService.textToPathData().
|
||||||
|
|
||||||
|
## Text Property Controls (in ShapeProperties)
|
||||||
|
When the selected object has type 'text', show:
|
||||||
|
1. **Text content** — textarea input bound to obj.text
|
||||||
|
2. **Font family** — dropdown/select populated from fontService.getAvailableFonts(), bound to obj.fontFamily
|
||||||
|
3. **Font size** — number input (min 8, max 200, step 1), bound to obj.fontSize
|
||||||
|
4. **Letter spacing** — number input (min -5, max 20, step 0.5), bound to obj.letterSpacing
|
||||||
|
5. **Line height** — number input (min 0.5, max 3, step 0.1), bound to obj.lineHeight
|
||||||
|
6. **Fill color** — color input (reuse existing pattern from hasFill), bound to obj.fill
|
||||||
|
7. **Stroke color + weight** — reuse existing stroke controls
|
||||||
|
8. **Convert to Paths button** — calls fontService.textToPathData(), then replaces the text object with a new path object (type 'image' with SVG data URL, or a new 'path' type if simpler)
|
||||||
|
|
||||||
|
## Convert to Paths Implementation
|
||||||
|
- Show confirmation dialog (window.confirm) since this is destructive
|
||||||
|
- Call `textToPathData(obj.text, obj.fontFamily, obj.fontSize, obj.letterSpacing)` from fontService
|
||||||
|
- Create SVG string from returned path data: `<svg xmlns=... viewBox=...><path d="{pathData}" fill="{obj.fill}" stroke="{obj.stroke}" stroke-width="{obj.strokeWidth}"/></svg>`
|
||||||
|
- Convert SVG to Blob URL, create ImageObject replacing the TextObject at same x,y position
|
||||||
|
- Use onUpdate to remove old text object and add new image object (or dispatch through a callback prop)
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
- fontService.ts from T01 provides loadFont(), getAvailableFonts(), textToPathData()
|
||||||
|
- The text property controls should only show when object.type === 'text'
|
||||||
|
- Existing stroke/fill/opacity/rotation controls still show for text objects
|
||||||
|
- ShapeProperties receives onUpdate callback from DesignCanvas — convert-to-paths needs onUpdate + onAddObject + onRemoveObject, so ShapeProperties props may need extending, or the convert action can be handled via a new callback prop
|
||||||
|
- DesignCanvas.tsx needs to pass the additional callback(s) to ShapeProperties
|
||||||
|
|
||||||
|
## Inputs
|
||||||
|
|
||||||
|
- ``app/src/utils/fontService.ts` — font loading and text-to-path conversion from T01`
|
||||||
|
- ``app/src/types/canvas.ts` — TextObject type from T02`
|
||||||
|
- ``app/src/components/canvas/ShapeProperties.tsx` — existing property panel to extend with text controls from T02`
|
||||||
|
- ``app/src/views/DesignCanvas.tsx` — existing view container that passes props to ShapeProperties`
|
||||||
|
|
||||||
|
## Expected Output
|
||||||
|
|
||||||
|
- ``app/src/components/canvas/ShapeProperties.tsx` — updated with text property controls (font picker, size, spacing, content) and Convert to Paths button`
|
||||||
|
- ``app/src/views/DesignCanvas.tsx` — updated to pass additional callbacks to ShapeProperties for convert-to-paths action`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
cd app && npx tsc --noEmit && npx vitest run --reporter=verbose
|
||||||
File diff suppressed because one or more lines are too long
29
app/package-lock.json
generated
29
app/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
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 ── */
|
/* ── Global component styles for Kerf Engine app ── */
|
||||||
|
|
||||||
/* File Upload Zone */
|
/* 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