From 75217ea6cb26a18779ef08aebc6317ff639fbbdc Mon Sep 17 00:00:00 2001 From: jlightner Date: Thu, 26 Mar 2026 06:26:09 +0000 Subject: [PATCH] =?UTF-8?q?test:=20Created=20exportService.ts=20with=20com?= =?UTF-8?q?poseCanvasSVG(),=20validateForExpo=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - "app/src/utils/exportService.ts" - "app/src/utils/__tests__/exportService.test.ts" - "app/src/api/engine.ts" - "app/src/api/__tests__/engine.test.ts" - "app/src/types/opentype.d.ts" GSD-Task: S01/T03 --- .gsd/event-log.jsonl | 1 + .gsd/milestones/M003/slices/S01/S01-PLAN.md | 2 +- .../M003/slices/S01/tasks/T02-VERIFY.json | 30 ++ .../M003/slices/S01/tasks/T03-SUMMARY.md | 87 ++++ .gsd/state-manifest.json | 68 ++- app/src/api/__tests__/engine.test.ts | 71 +++- app/src/api/engine.ts | 34 ++ .../hooks/__tests__/useDebouncedTrace.test.ts | 4 +- app/src/types/opentype.d.ts | 33 ++ app/src/utils/__tests__/exportService.test.ts | 401 ++++++++++++++++++ app/src/utils/__tests__/fontService.test.ts | 7 +- app/src/utils/exportService.ts | 289 +++++++++++++ app/src/views/ImportConvert.tsx | 2 +- app/vite.config.ts | 2 +- 14 files changed, 1012 insertions(+), 19 deletions(-) create mode 100644 .gsd/milestones/M003/slices/S01/tasks/T02-VERIFY.json create mode 100644 .gsd/milestones/M003/slices/S01/tasks/T03-SUMMARY.md create mode 100644 app/src/types/opentype.d.ts create mode 100644 app/src/utils/__tests__/exportService.test.ts create mode 100644 app/src/utils/exportService.ts diff --git a/.gsd/event-log.jsonl b/.gsd/event-log.jsonl index 882be30..b9e04ab 100644 --- a/.gsd/event-log.jsonl +++ b/.gsd/event-log.jsonl @@ -33,3 +33,4 @@ {"cmd":"plan-slice","params":{"milestoneId":"M003","sliceId":"S01"},"ts":"2026-03-26T06:13:19.433Z","actor":"agent","hash":"0c1c14d2a8ee7643","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T01"},"ts":"2026-03-26T06:16:59.236Z","actor":"agent","hash":"f6bd52e1fbbe7e7f","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} {"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T02"},"ts":"2026-03-26T06:19:28.695Z","actor":"agent","hash":"20e62f4b5af835c3","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} +{"cmd":"complete-task","params":{"milestoneId":"M003","sliceId":"S01","taskId":"T03"},"ts":"2026-03-26T06:26:04.608Z","actor":"agent","hash":"b3de5441cc811cf7","session_id":"49f8e0fe-34a0-4608-b519-eca93850ed7c"} diff --git a/.gsd/milestones/M003/slices/S01/S01-PLAN.md b/.gsd/milestones/M003/slices/S01/S01-PLAN.md index ab60728..3f06042 100644 --- a/.gsd/milestones/M003/slices/S01/S01-PLAN.md +++ b/.gsd/milestones/M003/slices/S01/S01-PLAN.md @@ -19,7 +19,7 @@ Key constraints: - Estimate: 1.5h - Files: app/src/App.tsx, app/src/views/DesignCanvas.tsx - Verify: cd app && npx tsc -b --noEmit && npx vitest run -- [ ] **T03: Build export service with SVG composition, validation, and DXF API client** — This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup. +- [x] **T03: Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors** — This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup. SVG composition details: - The SVG viewBox matches artboard dimensions in pixels diff --git a/.gsd/milestones/M003/slices/S01/tasks/T02-VERIFY.json b/.gsd/milestones/M003/slices/S01/tasks/T02-VERIFY.json new file mode 100644 index 0000000..3986edd --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T02-VERIFY.json @@ -0,0 +1,30 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M003/S01/T02", + "timestamp": 1774505970536, + "passed": false, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd app", + "exitCode": 0, + "durationMs": 5, + "verdict": "pass" + }, + { + "command": "npx tsc -b --noEmit", + "exitCode": 1, + "durationMs": 830, + "verdict": "fail" + }, + { + "command": "npx vitest run", + "exitCode": 1, + "durationMs": 1563, + "verdict": "fail" + } + ], + "retryAttempt": 1, + "maxRetries": 2 +} diff --git a/.gsd/milestones/M003/slices/S01/tasks/T03-SUMMARY.md b/.gsd/milestones/M003/slices/S01/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..e3c0e63 --- /dev/null +++ b/.gsd/milestones/M003/slices/S01/tasks/T03-SUMMARY.md @@ -0,0 +1,87 @@ +--- +id: T03 +parent: S01 +milestone: M003 +provides: [] +requires: [] +affects: [] +key_files: ["app/src/utils/exportService.ts", "app/src/utils/__tests__/exportService.test.ts", "app/src/api/engine.ts", "app/src/api/__tests__/engine.test.ts", "app/src/types/opentype.d.ts"] +key_decisions: ["SVG images detected by checking src for 'image/svg+xml' or .svg extension; raster images silently skipped in composition", "triggerDownload uses setTimeout(100ms) cleanup pattern to ensure download starts before revoking blob URL", "exportAsDxf sends SVG as Blob with filename 'export.svg' via FormData, returns raw blob response", "Export validation returns {valid, issues[]} with severity levels for UI to render blocking errors vs warnings"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Ran cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass. Full suite: cd app && npx vitest run — 120/120 pass across 8 test files. cd app && npx tsc -b --noEmit — exit 0, zero errors." +completed_at: 2026-03-26T06:26:04.560Z +blocker_discovered: false +--- + +# T03: Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors + +> Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors + +## What Happened +--- +id: T03 +parent: S01 +milestone: M003 +key_files: + - app/src/utils/exportService.ts + - app/src/utils/__tests__/exportService.test.ts + - app/src/api/engine.ts + - app/src/api/__tests__/engine.test.ts + - app/src/types/opentype.d.ts +key_decisions: + - SVG images detected by checking src for 'image/svg+xml' or .svg extension; raster images silently skipped in composition + - triggerDownload uses setTimeout(100ms) cleanup pattern to ensure download starts before revoking blob URL + - exportAsDxf sends SVG as Blob with filename 'export.svg' via FormData, returns raw blob response + - Export validation returns {valid, issues[]} with severity levels for UI to render blocking errors vs warnings +duration: "" +verification_result: passed +completed_at: 2026-03-26T06:26:04.572Z +blocker_discovered: false +--- + +# T03: Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors + +**Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors** + +## What Happened + +Created app/src/utils/exportService.ts with three exported functions: validateForExport() checks for blocking errors (text objects, missing artboard) and warnings (raster images), composeCanvasSVG() renders visible canvas objects into SVG with correct viewBox/real-world unit dimensions, triggerDownload() creates hidden anchor + blob URL for browser download. Added exportAsDxf() to app/src/api/engine.ts that POSTs SVG as FormData to /engine/simplify with output_format=dxf, units, and scale_factor, returning raw DXF blob. Also fixed 8 pre-existing tsc errors in unrelated files that were blocking the verification gate. + +## Verification + +Ran cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass. Full suite: cd app && npx vitest run — 120/120 pass across 8 test files. cd app && npx tsc -b --noEmit — exit 0, zero errors. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts` | 0 | ✅ pass (34/34) | 911ms | +| 2 | `cd app && npx vitest run` | 0 | ✅ pass (120/120) | 2480ms | +| 3 | `cd app && npx tsc -b --noEmit` | 0 | ✅ pass | 6000ms | + + +## Deviations + +Fixed 8 pre-existing tsc errors in files outside T03 scope (useDebouncedTrace.test.ts, fontService.test.ts, fontService.ts, ImportConvert.tsx, vite.config.ts) that were blocking the npx tsc -b --noEmit verification gate. + +## Known Issues + +None. + +## Files Created/Modified + +- `app/src/utils/exportService.ts` +- `app/src/utils/__tests__/exportService.test.ts` +- `app/src/api/engine.ts` +- `app/src/api/__tests__/engine.test.ts` +- `app/src/types/opentype.d.ts` + + +## Deviations +Fixed 8 pre-existing tsc errors in files outside T03 scope (useDebouncedTrace.test.ts, fontService.test.ts, fontService.ts, ImportConvert.tsx, vite.config.ts) that were blocking the npx tsc -b --noEmit verification gate. + +## Known Issues +None. diff --git a/.gsd/state-manifest.json b/.gsd/state-manifest.json index 2c2f120..9577ee6 100644 --- a/.gsd/state-manifest.json +++ b/.gsd/state-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "exported_at": "2026-03-26T06:19:28.693Z", + "exported_at": "2026-03-26T06:26:04.605Z", "milestones": [ { "id": "M001", @@ -1521,19 +1521,30 @@ "milestone_id": "M003", "slice_id": "S01", "id": "T03", - "title": "Build export service with SVG composition, validation, and DXF API client", - "status": "pending", - "one_liner": "", - "narrative": "", - "verification_result": "", + "title": "Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors", + "status": "complete", + "one_liner": "Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors", + "narrative": "Created app/src/utils/exportService.ts with three exported functions: validateForExport() checks for blocking errors (text objects, missing artboard) and warnings (raster images), composeCanvasSVG() renders visible canvas objects into SVG with correct viewBox/real-world unit dimensions, triggerDownload() creates hidden anchor + blob URL for browser download. Added exportAsDxf() to app/src/api/engine.ts that POSTs SVG as FormData to /engine/simplify with output_format=dxf, units, and scale_factor, returning raw DXF blob. Also fixed 8 pre-existing tsc errors in unrelated files that were blocking the verification gate.", + "verification_result": "Ran cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass. Full suite: cd app && npx vitest run — 120/120 pass across 8 test files. cd app && npx tsc -b --noEmit — exit 0, zero errors.", "duration": "", - "completed_at": null, + "completed_at": "2026-03-26T06:26:04.560Z", "blocker_discovered": false, - "deviations": "", - "known_issues": "", - "key_files": [], - "key_decisions": [], - "full_summary_md": "", + "deviations": "Fixed 8 pre-existing tsc errors in files outside T03 scope (useDebouncedTrace.test.ts, fontService.test.ts, fontService.ts, ImportConvert.tsx, vite.config.ts) that were blocking the npx tsc -b --noEmit verification gate.", + "known_issues": "None.", + "key_files": [ + "app/src/utils/exportService.ts", + "app/src/utils/__tests__/exportService.test.ts", + "app/src/api/engine.ts", + "app/src/api/__tests__/engine.test.ts", + "app/src/types/opentype.d.ts" + ], + "key_decisions": [ + "SVG images detected by checking src for 'image/svg+xml' or .svg extension; raster images silently skipped in composition", + "triggerDownload uses setTimeout(100ms) cleanup pattern to ensure download starts before revoking blob URL", + "exportAsDxf sends SVG as Blob with filename 'export.svg' via FormData, returns raw blob response", + "Export validation returns {valid, issues[]} with severity levels for UI to render blocking errors vs warnings" + ], + "full_summary_md": "---\nid: T03\nparent: S01\nmilestone: M003\nkey_files:\n - app/src/utils/exportService.ts\n - app/src/utils/__tests__/exportService.test.ts\n - app/src/api/engine.ts\n - app/src/api/__tests__/engine.test.ts\n - app/src/types/opentype.d.ts\nkey_decisions:\n - SVG images detected by checking src for 'image/svg+xml' or .svg extension; raster images silently skipped in composition\n - triggerDownload uses setTimeout(100ms) cleanup pattern to ensure download starts before revoking blob URL\n - exportAsDxf sends SVG as Blob with filename 'export.svg' via FormData, returns raw blob response\n - Export validation returns {valid, issues[]} with severity levels for UI to render blocking errors vs warnings\nduration: \"\"\nverification_result: passed\ncompleted_at: 2026-03-26T06:26:04.572Z\nblocker_discovered: false\n---\n\n# T03: Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors\n\n**Created exportService.ts with composeCanvasSVG(), validateForExport(), triggerDownload() and added exportAsDxf() to engine API client — 120/120 tests pass, zero tsc errors**\n\n## What Happened\n\nCreated app/src/utils/exportService.ts with three exported functions: validateForExport() checks for blocking errors (text objects, missing artboard) and warnings (raster images), composeCanvasSVG() renders visible canvas objects into SVG with correct viewBox/real-world unit dimensions, triggerDownload() creates hidden anchor + blob URL for browser download. Added exportAsDxf() to app/src/api/engine.ts that POSTs SVG as FormData to /engine/simplify with output_format=dxf, units, and scale_factor, returning raw DXF blob. Also fixed 8 pre-existing tsc errors in unrelated files that were blocking the verification gate.\n\n## Verification\n\nRan cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts — 34/34 pass. Full suite: cd app && npx vitest run — 120/120 pass across 8 test files. cd app && npx tsc -b --noEmit — exit 0, zero errors.\n\n## Verification Evidence\n\n| # | Command | Exit Code | Verdict | Duration |\n|---|---------|-----------|---------|----------|\n| 1 | `cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts` | 0 | ✅ pass (34/34) | 911ms |\n| 2 | `cd app && npx vitest run` | 0 | ✅ pass (120/120) | 2480ms |\n| 3 | `cd app && npx tsc -b --noEmit` | 0 | ✅ pass | 6000ms |\n\n\n## Deviations\n\nFixed 8 pre-existing tsc errors in files outside T03 scope (useDebouncedTrace.test.ts, fontService.test.ts, fontService.ts, ImportConvert.tsx, vite.config.ts) that were blocking the npx tsc -b --noEmit verification gate.\n\n## Known Issues\n\nNone.\n\n## Files Created/Modified\n\n- `app/src/utils/exportService.ts`\n- `app/src/utils/__tests__/exportService.test.ts`\n- `app/src/api/engine.ts`\n- `app/src/api/__tests__/engine.test.ts`\n- `app/src/types/opentype.d.ts`\n", "description": "This task creates the core export logic as pure utility functions: (1) `composeCanvasSVG()` renders visible canvas objects into an SVG string with correct coordinate space and real-world unit dimensions, (2) `validateForExport()` checks for unconverted text objects (blocking error), raster-only images (warning), and missing artboard (blocking error), (3) `exportDxf()` in the API client POSTs composed SVG to `/engine/simplify?output_format=dxf` with `units` and `scale_factor` params and returns a Blob, (4) `triggerDownload()` creates a hidden anchor + blob URL to trigger browser download with proper cleanup.\n\nSVG composition details:\n- The SVG viewBox matches artboard dimensions in pixels\n- `width`/`height` attributes use real-world units (e.g., `width=\"4in\"` or `width=\"101.6mm\"`)\n- RectObject → ``, CircleObject → ``, EllipseObject → ``, LineObject → ``\n- ImageObject with SVG blob src → inline the SVG content (extract path data from blob URL)\n- ImageObject with raster src → skip (validation warns about this)\n- TextObject → error (validation blocks this — must be converted to paths first)\n- Objects with `visible: false` are skipped\n- All coordinates are in the artboard's pixel space (the engine handles conversion via scale_factor)\n\nDXF API client:\n- New function `exportAsDxf(svgContent: string, units: 'inches' | 'mm', scaleFactor: number, signal?: AbortSignal): Promise`\n- Uses FormData with file as Blob, output_format=dxf, units, scale_factor\n- Returns `response.blob()` not `response.json()`\n\nUnit tests cover: SVG composition with known objects produces correct SVG elements, validation catches text objects, validation warns on raster images, coordinate space is correct.", "estimate": "2h", "files": [ @@ -2174,6 +2185,39 @@ "verdict": "✅ pass (36/36)", "duration_ms": 390, "created_at": "2026-03-26T06:19:28.653Z" + }, + { + "id": 43, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M003", + "command": "cd app && npx vitest run src/utils/__tests__/exportService.test.ts src/api/__tests__/engine.test.ts", + "exit_code": 0, + "verdict": "✅ pass (34/34)", + "duration_ms": 911, + "created_at": "2026-03-26T06:26:04.560Z" + }, + { + "id": 44, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M003", + "command": "cd app && npx vitest run", + "exit_code": 0, + "verdict": "✅ pass (120/120)", + "duration_ms": 2480, + "created_at": "2026-03-26T06:26:04.560Z" + }, + { + "id": 45, + "task_id": "T03", + "slice_id": "S01", + "milestone_id": "M003", + "command": "cd app && npx tsc -b --noEmit", + "exit_code": 0, + "verdict": "✅ pass", + "duration_ms": 6000, + "created_at": "2026-03-26T06:26:04.560Z" } ] } \ No newline at end of file diff --git a/app/src/api/__tests__/engine.test.ts b/app/src/api/__tests__/engine.test.ts index 9af4897..4d75f20 100644 --- a/app/src/api/__tests__/engine.test.ts +++ b/app/src/api/__tests__/engine.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { getPresets, traceImage, simplifyVector } from '../engine'; +import { getPresets, traceImage, simplifyVector, exportAsDxf } from '../engine'; // ---------- helpers ---------- @@ -147,3 +147,72 @@ describe('simplifyVector', () => { await expect(simplifyVector(file, 1.0)).rejects.toThrow(/failed.*400/i); }); }); + +// ---------- exportAsDxf ---------- + +function mockFetchBlob(blobContent: string) { + return vi.fn().mockResolvedValue({ + ok: true, + status: 200, + blob: () => Promise.resolve(new Blob([blobContent], { type: 'application/octet-stream' })), + text: () => Promise.resolve(blobContent), + }); +} + +describe('exportAsDxf', () => { + it('sends FormData with SVG content, output_format=dxf, units, and scale_factor', async () => { + globalThis.fetch = mockFetchBlob('DXF_CONTENT_BINARY'); + + const svgContent = ''; + const result = await exportAsDxf(svgContent, 'inches', 0.010416667); + + expect(globalThis.fetch).toHaveBeenCalledOnce(); + const [url, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toBe('/engine/simplify'); + expect(opts.method).toBe('POST'); + + const body: FormData = opts.body; + expect(body).toBeInstanceOf(FormData); + expect(body.get('output_format')).toBe('dxf'); + expect(body.get('units')).toBe('inches'); + expect(body.get('scale_factor')).toBe('0.010416667'); + + // File should be present as a Blob + const file = body.get('file'); + expect(file).toBeInstanceOf(Blob); + + // Result should be a Blob + expect(result).toBeInstanceOf(Blob); + const text = await result.text(); + expect(text).toBe('DXF_CONTENT_BINARY'); + }); + + it('sends mm units correctly', async () => { + globalThis.fetch = mockFetchBlob('DXF_MM'); + + await exportAsDxf('', 'mm', 0.264583333); + + const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + const body: FormData = opts.body; + expect(body.get('units')).toBe('mm'); + expect(body.get('scale_factor')).toBe('0.264583333'); + }); + + it('passes AbortSignal when provided', async () => { + globalThis.fetch = mockFetchBlob('DXF'); + const controller = new AbortController(); + + await exportAsDxf('', 'inches', 1.0, controller.signal); + + const [, opts] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(opts.signal).toBe(controller.signal); + }); + + it('throws on non-ok response', async () => { + globalThis.fetch = mockFetchFail(500, 'Internal Server Error'); + + await expect( + exportAsDxf('', 'inches', 1.0), + ).rejects.toThrow(/DXF export.*failed.*500/i); + }); +}); diff --git a/app/src/api/engine.ts b/app/src/api/engine.ts index e4609cc..c8f7f7d 100644 --- a/app/src/api/engine.ts +++ b/app/src/api/engine.ts @@ -67,3 +67,37 @@ export async function simplifyVector( } return res.json() as Promise; } + +/** + * Export an SVG as DXF via the engine's simplify endpoint. + * + * Posts the SVG content as a file with output_format=dxf, along with + * units and scale_factor parameters. Returns the raw DXF binary as a Blob. + */ +export async function exportAsDxf( + svgContent: string, + units: 'inches' | 'mm', + scaleFactor: number, + signal?: AbortSignal, +): Promise { + const form = new FormData(); + form.append( + 'file', + new Blob([svgContent], { type: 'image/svg+xml' }), + 'export.svg', + ); + form.append('output_format', 'dxf'); + form.append('units', units); + form.append('scale_factor', String(scaleFactor)); + + const res = await fetch(`${BASE}/simplify`, { + method: 'POST', + body: form, + signal, + }); + if (!res.ok) { + const detail = await res.text().catch(() => res.statusText); + throw new Error(`POST /engine/simplify (DXF export) failed: ${res.status} — ${detail}`); + } + return res.blob(); +} diff --git a/app/src/hooks/__tests__/useDebouncedTrace.test.ts b/app/src/hooks/__tests__/useDebouncedTrace.test.ts index 9128ff4..f2a2657 100644 --- a/app/src/hooks/__tests__/useDebouncedTrace.test.ts +++ b/app/src/hooks/__tests__/useDebouncedTrace.test.ts @@ -88,7 +88,7 @@ describe('useDebouncedTrace', () => { }); it('passes AbortSignal to fetch and aborts previous requests', async () => { - let firstSignal: AbortSignal | undefined; + let firstSignal: AbortSignal | null | undefined; let callCount = 0; globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => { callCount++; @@ -105,7 +105,7 @@ describe('useDebouncedTrace', () => { const file = new File(['pixels'], 'test.png', { type: 'image/png' }); - const { result, rerender } = renderHook( + const { rerender } = renderHook( ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS), { initialProps: { params: { epsilon: 1 } as Record } }, ); diff --git a/app/src/types/opentype.d.ts b/app/src/types/opentype.d.ts new file mode 100644 index 0000000..b676e0e --- /dev/null +++ b/app/src/types/opentype.d.ts @@ -0,0 +1,33 @@ +declare module 'opentype.js' { + interface Glyph { + advanceWidth: number; + getPath(x: number, y: number, fontSize: number): Path; + } + + interface PathCommand { + type: string; + x?: number; + y?: number; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + } + + interface Path { + commands: PathCommand[]; + toPathData(decimalPlaces?: number): string; + } + + interface Font { + unitsPerEm: number; + ascender: number; + descender: number; + charToGlyph(char: string): Glyph; + getPath(text: string, x: number, y: number, fontSize: number): Path; + stringToGlyphs(text: string): Glyph[]; + } + + export function parse(buffer: ArrayBuffer): Font; + export function load(url: string, callback: (err: Error | null, font?: Font) => void): void; +} diff --git a/app/src/utils/__tests__/exportService.test.ts b/app/src/utils/__tests__/exportService.test.ts new file mode 100644 index 0000000..f4a7a3a --- /dev/null +++ b/app/src/utils/__tests__/exportService.test.ts @@ -0,0 +1,401 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { + composeCanvasSVG, + validateForExport, + triggerDownload, +} from '../exportService'; +import type { + ArtboardConfig, + CanvasObject, + RectObject, + CircleObject, + EllipseObject, + LineObject, + ImageObject, + TextObject, +} from '../../types/canvas'; + +// -- Helpers ------------------------------------------------------------------ + +function makeArtboard(overrides?: Partial): ArtboardConfig { + return { + shape: 'rect', + width: 4, + height: 6, + unit: 'inches', + ...overrides, + }; +} + +function makeRect(overrides?: Partial): RectObject { + return { + id: 'rect-1', + name: 'Rectangle 1', + type: 'rect', + x: 10, + y: 20, + width: 100, + height: 50, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#ff0000', + stroke: '#000000', + strokeWidth: 2, + ...overrides, + }; +} + +function makeCircle(overrides?: Partial): CircleObject { + return { + id: 'circle-1', + name: 'Circle 1', + type: 'circle', + x: 50, + y: 50, + radius: 25, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#00ff00', + stroke: '#000000', + strokeWidth: 1, + ...overrides, + }; +} + +function makeEllipse(overrides?: Partial): EllipseObject { + return { + id: 'ellipse-1', + name: 'Ellipse 1', + type: 'ellipse', + x: 100, + y: 100, + radiusX: 40, + radiusY: 20, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + fill: '#0000ff', + stroke: '#000000', + strokeWidth: 1, + ...overrides, + }; +} + +function makeLine(overrides?: Partial): LineObject { + return { + id: 'line-1', + name: 'Line 1', + type: 'line', + x: 0, + y: 0, + points: [10, 20, 30, 40, 50, 60], + rotation: 0, + visible: true, + locked: false, + opacity: 1, + stroke: '#333333', + strokeWidth: 3, + lineStyle: 'solid', + dash: [], + ...overrides, + }; +} + +function makeImage(overrides?: Partial): ImageObject { + return { + id: 'img-1', + name: 'Image 1', + type: 'image', + x: 10, + y: 10, + width: 200, + height: 150, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + src: 'blob:http://localhost:3000/abc-image/svg+xml', + ...overrides, + }; +} + +function makeText(overrides?: Partial): TextObject { + return { + id: 'text-1', + name: 'Text 1', + type: 'text', + x: 10, + y: 10, + text: 'Hello', + fontFamily: 'Roboto', + fontSize: 24, + letterSpacing: 0, + lineHeight: 1.2, + fill: '#000000', + stroke: '', + strokeWidth: 0, + width: 200, + rotation: 0, + visible: true, + locked: false, + opacity: 1, + ...overrides, + }; +} + +// -- validateForExport -------------------------------------------------------- + +describe('validateForExport', () => { + it('returns valid=true with no issues for clean objects', () => { + const artboard = makeArtboard(); + const objects: CanvasObject[] = [makeRect(), makeCircle()]; + const result = validateForExport(objects, artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('returns error when artboard is null', () => { + const result = validateForExport([makeRect()], null); + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0].severity).toBe('error'); + expect(result.issues[0].message).toMatch(/artboard/i); + }); + + it('returns error for visible text objects', () => { + const artboard = makeArtboard(); + const text = makeText(); + const result = validateForExport([text], artboard); + expect(result.valid).toBe(false); + const textIssue = result.issues.find( + (i) => i.objectId === 'text-1', + ); + expect(textIssue).toBeDefined(); + expect(textIssue!.severity).toBe('error'); + expect(textIssue!.message).toMatch(/converted to paths/i); + }); + + it('ignores hidden text objects', () => { + const artboard = makeArtboard(); + const text = makeText({ visible: false }); + const result = validateForExport([text], artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('warns on raster images', () => { + const artboard = makeArtboard(); + const img = makeImage({ src: 'blob:http://localhost:3000/raster-png' }); + const result = validateForExport([img], artboard); + expect(result.valid).toBe(true); + const imgIssue = result.issues.find( + (i) => i.objectId === 'img-1', + ); + expect(imgIssue).toBeDefined(); + expect(imgIssue!.severity).toBe('warning'); + expect(imgIssue!.message).toMatch(/raster/i); + }); + + it('does not warn on SVG image sources', () => { + const artboard = makeArtboard(); + const img = makeImage({ + src: 'blob:http://localhost:3000/abc-image/svg+xml', + }); + const result = validateForExport([img], artboard); + expect(result.valid).toBe(true); + expect(result.issues).toHaveLength(0); + }); + + it('collects multiple issues', () => { + const text = makeText(); + const rasterImg = makeImage({ + id: 'img-raster', + name: 'Raster', + src: 'data:image/png;base64,abc', + }); + const result = validateForExport([text, rasterImg], null); + expect(result.valid).toBe(false); + // artboard error + text error + raster warning = 3 + expect(result.issues).toHaveLength(3); + const errors = result.issues.filter((i) => i.severity === 'error'); + const warnings = result.issues.filter((i) => i.severity === 'warning'); + expect(errors).toHaveLength(2); + expect(warnings).toHaveLength(1); + }); +}); + +// -- composeCanvasSVG --------------------------------------------------------- + +describe('composeCanvasSVG', () => { + it('produces SVG with correct viewBox and real-world dimensions (inches)', () => { + const artboard = makeArtboard({ width: 4, height: 6, unit: 'inches' }); + const svg = composeCanvasSVG([], artboard); + // 4in * 96 PPI = 384px, 6in * 96 PPI = 576px + expect(svg).toContain('viewBox="0 0 384 576"'); + expect(svg).toContain('width="4in"'); + expect(svg).toContain('height="6in"'); + }); + + it('produces SVG with correct dimensions (mm)', () => { + const artboard = makeArtboard({ width: 100, height: 200, unit: 'mm' }); + const svg = composeCanvasSVG([], artboard); + expect(svg).toContain('width="100mm"'); + expect(svg).toContain('height="200mm"'); + // 100mm * (96/25.4) ≈ 377.9528 + expect(svg).toMatch(/viewBox="0 0 377\.95\d* 755\.90\d*"/); + }); + + it('renders rect objects correctly', () => { + const artboard = makeArtboard(); + const rect = makeRect({ x: 10, y: 20, width: 100, height: 50 }); + const svg = composeCanvasSVG([rect], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const circle = makeCircle({ x: 50, y: 50, radius: 25 }); + const svg = composeCanvasSVG([circle], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const ellipse = makeEllipse({ x: 100, y: 100, radiusX: 40, radiusY: 20 }); + const svg = composeCanvasSVG([ellipse], artboard); + expect(svg).toContain(' { + const artboard = makeArtboard(); + const line = makeLine({ x: 5, y: 10, points: [0, 0, 20, 30] }); + const svg = composeCanvasSVG([line], artboard); + expect(svg).toContain(' elements', () => { + const artboard = makeArtboard(); + const img = makeImage({ + src: 'blob:http://localhost:3000/abc-image/svg+xml', + }); + const svg = composeCanvasSVG([img], artboard); + expect(svg).toContain('