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(' {
+ const artboard = makeArtboard();
+ const rect = makeRect({ visible: false });
+ const svg = composeCanvasSVG([rect], artboard);
+ expect(svg).not.toContain(' {
+ const artboard = makeArtboard();
+ const text = makeText();
+ const svg = composeCanvasSVG([text], artboard);
+ expect(svg).not.toContain(' {
+ const artboard = makeArtboard();
+ const img = makeImage({
+ src: 'data:image/png;base64,abc',
+ });
+ const svg = composeCanvasSVG([img], artboard);
+ expect(svg).not.toContain(' {
+ const artboard = makeArtboard();
+ const rect = makeRect({ rotation: 45, x: 10, y: 20, width: 100, height: 50 });
+ const svg = composeCanvasSVG([rect], artboard);
+ // Center: (10+50, 20+25) = (60, 45)
+ expect(svg).toContain('transform="rotate(45 60 45)"');
+ });
+
+ it('renders multiple objects in order', () => {
+ const artboard = makeArtboard();
+ const objects: CanvasObject[] = [
+ makeRect({ id: 'r1', name: 'R1' }),
+ makeCircle({ id: 'c1', name: 'C1' }),
+ ];
+ const svg = composeCanvasSVG(objects, artboard);
+ const rectIdx = svg.indexOf(' {
+ const artboard = makeArtboard();
+ const line = makeLine({ dash: [5, 3] });
+ const svg = composeCanvasSVG([line], artboard);
+ expect(svg).toContain('stroke-dasharray="5 3"');
+ });
+});
+
+// -- triggerDownload ----------------------------------------------------------
+
+describe('triggerDownload', () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('creates a blob URL and triggers a download', () => {
+ // Activate fake timers before calling triggerDownload so setTimeout is captured
+ vi.useFakeTimers();
+
+ const blob = new Blob(['test content'], { type: 'text/plain' });
+ const mockUrl = 'blob:http://localhost:3000/mock-blob-url';
+
+ const createObjectURLSpy = vi
+ .spyOn(URL, 'createObjectURL')
+ .mockReturnValue(mockUrl);
+ const revokeObjectURLSpy = vi
+ .spyOn(URL, 'revokeObjectURL')
+ .mockImplementation(() => {});
+
+ const clickSpy = vi.fn();
+ const appendChildSpy = vi.spyOn(document.body, 'appendChild');
+ const removeChildSpy = vi.spyOn(document.body, 'removeChild');
+
+ // Mock createElement to return a spy-able anchor
+ const mockAnchor = document.createElement('a');
+ mockAnchor.click = clickSpy;
+ vi.spyOn(document, 'createElement').mockReturnValue(mockAnchor);
+
+ triggerDownload(blob, 'test-file.svg');
+
+ expect(createObjectURLSpy).toHaveBeenCalledWith(blob);
+ expect(mockAnchor.href).toBe(mockUrl);
+ expect(mockAnchor.download).toBe('test-file.svg');
+ expect(clickSpy).toHaveBeenCalledOnce();
+ expect(appendChildSpy).toHaveBeenCalledWith(mockAnchor);
+
+ // Cleanup happens after setTimeout — advance timers
+ vi.advanceTimersByTime(150);
+ expect(removeChildSpy).toHaveBeenCalled();
+ expect(revokeObjectURLSpy).toHaveBeenCalledWith(mockUrl);
+ vi.useRealTimers();
+ });
+});
diff --git a/app/src/utils/__tests__/fontService.test.ts b/app/src/utils/__tests__/fontService.test.ts
index f3d807c..611c645 100644
--- a/app/src/utils/__tests__/fontService.test.ts
+++ b/app/src/utils/__tests__/fontService.test.ts
@@ -1,6 +1,8 @@
+///
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
+import { fileURLToPath } from 'node:url';
import {
loadFont,
loadFontByFamily,
@@ -10,13 +12,16 @@ import {
isFontCached,
} from '../fontService';
+const __filename_local = fileURLToPath(import.meta.url);
+const __dirname_local = path.dirname(__filename_local);
+
/**
* 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 fontPath = path.resolve(__dirname_local, '../../../public/fonts', filename);
const buffer = fs.readFileSync(fontPath);
return buffer.buffer.slice(
buffer.byteOffset,
diff --git a/app/src/utils/exportService.ts b/app/src/utils/exportService.ts
new file mode 100644
index 0000000..916d447
--- /dev/null
+++ b/app/src/utils/exportService.ts
@@ -0,0 +1,289 @@
+/**
+ * Export service: SVG composition, validation, and browser download trigger.
+ *
+ * Pure utility functions that operate on canvas state to produce export-ready
+ * SVG content. No React dependencies — consumed by ExportView (T04).
+ */
+
+import type {
+ CanvasObject,
+ ArtboardConfig,
+ RectObject,
+ CircleObject,
+ EllipseObject,
+ LineObject,
+ ImageObject,
+} from '../types/canvas';
+import { toPx } from './artboardShapes';
+
+// -- Validation ---------------------------------------------------------------
+
+export type ValidationSeverity = 'error' | 'warning';
+
+export interface ValidationIssue {
+ severity: ValidationSeverity;
+ message: string;
+ objectId?: string;
+ objectName?: string;
+}
+
+export interface ValidationResult {
+ valid: boolean;
+ issues: ValidationIssue[];
+}
+
+/**
+ * Validate canvas objects for export readiness.
+ *
+ * Blocking errors (valid=false):
+ * - TextObject present (must be converted to paths first)
+ * - Missing artboard (no artboard configured)
+ *
+ * Warnings (valid=true but with issues):
+ * - Raster-only images (non-SVG image sources)
+ */
+export function validateForExport(
+ objects: CanvasObject[],
+ artboard: ArtboardConfig | null,
+): ValidationResult {
+ const issues: ValidationIssue[] = [];
+
+ // Check for missing artboard
+ if (!artboard) {
+ issues.push({
+ severity: 'error',
+ message: 'No artboard configured. Set up an artboard before exporting.',
+ });
+ }
+
+ for (const obj of objects) {
+ if (!obj.visible) continue;
+
+ if (obj.type === 'text') {
+ issues.push({
+ severity: 'error',
+ message: `Text object "${obj.name}" must be converted to paths before export.`,
+ objectId: obj.id,
+ objectName: obj.name,
+ });
+ }
+
+ if (obj.type === 'image') {
+ // SVG blob URLs contain 'image/svg+xml' in their origin or are data:image/svg+xml
+ const isSvgSrc =
+ obj.src.includes('image/svg+xml') || obj.src.endsWith('.svg');
+ if (!isSvgSrc) {
+ issues.push({
+ severity: 'warning',
+ message: `Image "${obj.name}" is raster-only and will be skipped in vector export.`,
+ objectId: obj.id,
+ objectName: obj.name,
+ });
+ }
+ }
+ }
+
+ const hasErrors = issues.some((i) => i.severity === 'error');
+ return { valid: !hasErrors, issues };
+}
+
+// -- SVG Composition ----------------------------------------------------------
+
+/** Escape XML special characters in attribute values. */
+function escapeXml(str: string): string {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/** Format a number to a reasonable precision for SVG attributes. */
+function n(value: number): string {
+ return Number(value.toFixed(4)).toString();
+}
+
+function renderRect(obj: RectObject): string {
+ const attrs = [
+ `x="${n(obj.x)}"`,
+ `y="${n(obj.y)}"`,
+ `width="${n(obj.width)}"`,
+ `height="${n(obj.height)}"`,
+ `fill="${escapeXml(obj.fill)}"`,
+ `stroke="${escapeXml(obj.stroke)}"`,
+ `stroke-width="${n(obj.strokeWidth)}"`,
+ `opacity="${n(obj.opacity)}"`,
+ ];
+ if (obj.rotation !== 0) {
+ const cx = obj.x + obj.width / 2;
+ const cy = obj.y + obj.height / 2;
+ attrs.push(`transform="rotate(${n(obj.rotation)} ${n(cx)} ${n(cy)})"`);
+ }
+ return ` `;
+}
+
+function renderCircle(obj: CircleObject): string {
+ const attrs = [
+ `cx="${n(obj.x)}"`,
+ `cy="${n(obj.y)}"`,
+ `r="${n(obj.radius)}"`,
+ `fill="${escapeXml(obj.fill)}"`,
+ `stroke="${escapeXml(obj.stroke)}"`,
+ `stroke-width="${n(obj.strokeWidth)}"`,
+ `opacity="${n(obj.opacity)}"`,
+ ];
+ if (obj.rotation !== 0) {
+ attrs.push(`transform="rotate(${n(obj.rotation)} ${n(obj.x)} ${n(obj.y)})"`);
+ }
+ return ` `;
+}
+
+function renderEllipse(obj: EllipseObject): string {
+ const attrs = [
+ `cx="${n(obj.x)}"`,
+ `cy="${n(obj.y)}"`,
+ `rx="${n(obj.radiusX)}"`,
+ `ry="${n(obj.radiusY)}"`,
+ `fill="${escapeXml(obj.fill)}"`,
+ `stroke="${escapeXml(obj.stroke)}"`,
+ `stroke-width="${n(obj.strokeWidth)}"`,
+ `opacity="${n(obj.opacity)}"`,
+ ];
+ if (obj.rotation !== 0) {
+ attrs.push(`transform="rotate(${n(obj.rotation)} ${n(obj.x)} ${n(obj.y)})"`);
+ }
+ return ` `;
+}
+
+function renderLine(obj: LineObject): string {
+ // LineObject.points are [x1, y1, x2, y2, ...] relative to obj.x, obj.y
+ const pointPairs: string[] = [];
+ for (let i = 0; i < obj.points.length; i += 2) {
+ const px = obj.x + (obj.points[i] ?? 0);
+ const py = obj.y + (obj.points[i + 1] ?? 0);
+ pointPairs.push(`${n(px)},${n(py)}`);
+ }
+ const attrs = [
+ `points="${pointPairs.join(' ')}"`,
+ `fill="none"`,
+ `stroke="${escapeXml(obj.stroke)}"`,
+ `stroke-width="${n(obj.strokeWidth)}"`,
+ `opacity="${n(obj.opacity)}"`,
+ ];
+ if (obj.dash.length > 0) {
+ attrs.push(`stroke-dasharray="${obj.dash.map(n).join(' ')}"`);
+ }
+ if (obj.rotation !== 0) {
+ attrs.push(`transform="rotate(${n(obj.rotation)} ${n(obj.x)} ${n(obj.y)})"`);
+ }
+ return ` `;
+}
+
+function renderImage(obj: ImageObject): string {
+ // For SVG blob URLs, we can reference them. In a full implementation,
+ // we'd inline the SVG content by fetching the blob URL.
+ // For now, embed as with href.
+ const attrs = [
+ `x="${n(obj.x)}"`,
+ `y="${n(obj.y)}"`,
+ `width="${n(obj.width)}"`,
+ `height="${n(obj.height)}"`,
+ `href="${escapeXml(obj.src)}"`,
+ `opacity="${n(obj.opacity)}"`,
+ ];
+ if (obj.rotation !== 0) {
+ const cx = obj.x + obj.width / 2;
+ const cy = obj.y + obj.height / 2;
+ attrs.push(`transform="rotate(${n(obj.rotation)} ${n(cx)} ${n(cy)})"`);
+ }
+ return ` `;
+}
+
+/**
+ * Render a single canvas object to an SVG element string.
+ * Returns null for objects that cannot be rendered (text, hidden, raster images).
+ */
+function renderObject(obj: CanvasObject): string | null {
+ if (!obj.visible) return null;
+
+ switch (obj.type) {
+ case 'rect':
+ return renderRect(obj);
+ case 'circle':
+ return renderCircle(obj);
+ case 'ellipse':
+ return renderEllipse(obj);
+ case 'line':
+ return renderLine(obj);
+ case 'image': {
+ // Skip raster images — only SVG images are meaningful for vector export
+ const isSvgSrc =
+ obj.src.includes('image/svg+xml') || obj.src.endsWith('.svg');
+ if (!isSvgSrc) return null;
+ return renderImage(obj);
+ }
+ case 'text':
+ // Text objects should have been converted to paths. Skip in SVG composition.
+ return null;
+ default:
+ return null;
+ }
+}
+
+/**
+ * Compose visible canvas objects into an SVG string with correct coordinate
+ * space and real-world unit dimensions.
+ *
+ * - viewBox matches artboard dimensions in pixels
+ * - width/height attributes use real-world units (e.g. `width="4in"`)
+ * - Objects with `visible: false` are skipped
+ * - TextObject and raster ImageObject are skipped
+ */
+export function composeCanvasSVG(
+ objects: CanvasObject[],
+ artboard: ArtboardConfig,
+): string {
+ const pxW = toPx(artboard.width, artboard.unit);
+ const pxH = toPx(artboard.height, artboard.unit);
+
+ // Real-world unit suffix for width/height attributes
+ const unitSuffix = artboard.unit === 'inches' ? 'in' : 'mm';
+ const realW = `${n(artboard.width)}${unitSuffix}`;
+ const realH = `${n(artboard.height)}${unitSuffix}`;
+
+ const elements = objects
+ .map(renderObject)
+ .filter((el): el is string => el !== null);
+
+ const lines = [
+ `',
+ ];
+
+ return lines.join('\n');
+}
+
+// -- Download Trigger ---------------------------------------------------------
+
+/**
+ * Trigger a browser file download from a Blob.
+ *
+ * Creates a hidden anchor element with a blob URL, clicks it to start the
+ * download, then cleans up the DOM element and revokes the blob URL.
+ */
+export function triggerDownload(blob: Blob, filename: string): void {
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ a.style.display = 'none';
+ document.body.appendChild(a);
+ a.click();
+ // Clean up after a short delay to ensure the download starts
+ setTimeout(() => {
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }, 100);
+}
diff --git a/app/src/views/ImportConvert.tsx b/app/src/views/ImportConvert.tsx
index 5d3c0d1..121a7bf 100644
--- a/app/src/views/ImportConvert.tsx
+++ b/app/src/views/ImportConvert.tsx
@@ -34,7 +34,7 @@ function defaultParamsFromPreset(config: PresetConfig): Record
export default function ImportConvert({ onUseThis }: ImportConvertProps) {
const [selectedFile, setSelectedFile] = useState(null);
- const [isSvgMode, setIsSvgMode] = useState(false);
+ const [_isSvgMode, setIsSvgMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState('sign');
const [presetConfig, setPresetConfig] = useState(null);
const [currentParams, setCurrentParams] = useState>({
diff --git a/app/vite.config.ts b/app/vite.config.ts
index d39af25..dcaca3c 100644
--- a/app/vite.config.ts
+++ b/app/vite.config.ts
@@ -1,4 +1,4 @@
-///
+///
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'