test: Created exportService.ts with composeCanvasSVG(), validateForExpo…

- "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
This commit is contained in:
jlightner 2026-03-26 06:26:09 +00:00
parent 62c866be84
commit fa4c765860
9 changed files with 837 additions and 6 deletions

View file

@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { getPresets, traceImage, simplifyVector } from '../engine'; import { getPresets, traceImage, simplifyVector, exportAsDxf } from '../engine';
// ---------- helpers ---------- // ---------- helpers ----------
@ -147,3 +147,72 @@ describe('simplifyVector', () => {
await expect(simplifyVector(file, 1.0)).rejects.toThrow(/failed.*400/i); 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 = '<svg xmlns="http://www.w3.org/2000/svg"><rect width="100" height="100"/></svg>';
const result = await exportAsDxf(svgContent, 'inches', 0.010416667);
expect(globalThis.fetch).toHaveBeenCalledOnce();
const [url, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).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('<svg/>', 'mm', 0.264583333);
const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).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('<svg/>', 'inches', 1.0, controller.signal);
const [, opts] = (globalThis.fetch as ReturnType<typeof vi.fn>).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('<svg/>', 'inches', 1.0),
).rejects.toThrow(/DXF export.*failed.*500/i);
});
});

View file

@ -67,3 +67,37 @@ export async function simplifyVector(
} }
return res.json() as Promise<TraceResponse>; return res.json() as Promise<TraceResponse>;
} }
/**
* 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<Blob> {
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();
}

View file

@ -88,7 +88,7 @@ describe('useDebouncedTrace', () => {
}); });
it('passes AbortSignal to fetch and aborts previous requests', async () => { it('passes AbortSignal to fetch and aborts previous requests', async () => {
let firstSignal: AbortSignal | undefined; let firstSignal: AbortSignal | null | undefined;
let callCount = 0; let callCount = 0;
globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => { globalThis.fetch = vi.fn().mockImplementation((_url: string, opts?: RequestInit) => {
callCount++; callCount++;
@ -105,7 +105,7 @@ describe('useDebouncedTrace', () => {
const file = new File(['pixels'], 'test.png', { type: 'image/png' }); const file = new File(['pixels'], 'test.png', { type: 'image/png' });
const { result, rerender } = renderHook( const { rerender } = renderHook(
({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS), ({ params }) => useDebouncedTrace(file, 'sign', params, DEBOUNCE_MS),
{ initialProps: { params: { epsilon: 1 } as Record<string, unknown> } }, { initialProps: { params: { epsilon: 1 } as Record<string, unknown> } },
); );

33
app/src/types/opentype.d.ts vendored Normal file
View file

@ -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;
}

View file

@ -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>): ArtboardConfig {
return {
shape: 'rect',
width: 4,
height: 6,
unit: 'inches',
...overrides,
};
}
function makeRect(overrides?: Partial<RectObject>): 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>): 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>): 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>): 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>): 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>): 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('<rect');
expect(svg).toContain('x="10"');
expect(svg).toContain('y="20"');
expect(svg).toContain('width="100"');
expect(svg).toContain('height="50"');
});
it('renders circle objects correctly', () => {
const artboard = makeArtboard();
const circle = makeCircle({ x: 50, y: 50, radius: 25 });
const svg = composeCanvasSVG([circle], artboard);
expect(svg).toContain('<circle');
expect(svg).toContain('cx="50"');
expect(svg).toContain('cy="50"');
expect(svg).toContain('r="25"');
});
it('renders ellipse objects correctly', () => {
const artboard = makeArtboard();
const ellipse = makeEllipse({ x: 100, y: 100, radiusX: 40, radiusY: 20 });
const svg = composeCanvasSVG([ellipse], artboard);
expect(svg).toContain('<ellipse');
expect(svg).toContain('cx="100"');
expect(svg).toContain('cy="100"');
expect(svg).toContain('rx="40"');
expect(svg).toContain('ry="20"');
});
it('renders line objects as polyline', () => {
const artboard = makeArtboard();
const line = makeLine({ x: 5, y: 10, points: [0, 0, 20, 30] });
const svg = composeCanvasSVG([line], artboard);
expect(svg).toContain('<polyline');
// Points are offset by obj.x/obj.y: (5+0, 10+0) (5+20, 10+30) → "5,10 25,40"
expect(svg).toContain('points="5,10 25,40"');
expect(svg).toContain('fill="none"');
});
it('renders SVG images as <image> elements', () => {
const artboard = makeArtboard();
const img = makeImage({
src: 'blob:http://localhost:3000/abc-image/svg+xml',
});
const svg = composeCanvasSVG([img], artboard);
expect(svg).toContain('<image');
expect(svg).toContain('href=');
});
it('skips hidden objects', () => {
const artboard = makeArtboard();
const rect = makeRect({ visible: false });
const svg = composeCanvasSVG([rect], artboard);
expect(svg).not.toContain('<rect');
});
it('skips text objects', () => {
const artboard = makeArtboard();
const text = makeText();
const svg = composeCanvasSVG([text], artboard);
expect(svg).not.toContain('<text');
expect(svg).not.toContain('Hello');
});
it('skips raster images', () => {
const artboard = makeArtboard();
const img = makeImage({
src: 'data:image/png;base64,abc',
});
const svg = composeCanvasSVG([img], artboard);
expect(svg).not.toContain('<image');
});
it('applies rotation transform when non-zero', () => {
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('<rect');
const circleIdx = svg.indexOf('<circle');
expect(rectIdx).toBeLessThan(circleIdx);
});
it('includes stroke-dasharray for dashed lines', () => {
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();
});
});

View file

@ -1,6 +1,8 @@
/// <reference types="node" />
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import { import {
loadFont, loadFont,
loadFontByFamily, loadFontByFamily,
@ -10,13 +12,16 @@ import {
isFontCached, isFontCached,
} from '../fontService'; } 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. * 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 * This lets us mock fetch() with real font data so opentype.js
* produces genuine glyph paths, proving the integration end-to-end. * produces genuine glyph paths, proving the integration end-to-end.
*/ */
function readFontFile(filename: string): ArrayBuffer { 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); const buffer = fs.readFileSync(fontPath);
return buffer.buffer.slice( return buffer.buffer.slice(
buffer.byteOffset, buffer.byteOffset,

View file

@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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 ` <rect ${attrs.join(' ')} />`;
}
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 ` <circle ${attrs.join(' ')} />`;
}
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 ` <ellipse ${attrs.join(' ')} />`;
}
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 ` <polyline ${attrs.join(' ')} />`;
}
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 <image> 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 ` <image ${attrs.join(' ')} />`;
}
/**
* 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 = [
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${n(pxW)} ${n(pxH)}" width="${realW}" height="${realH}">`,
...elements,
'</svg>',
];
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);
}

View file

@ -34,7 +34,7 @@ function defaultParamsFromPreset(config: PresetConfig): Record<string, unknown>
export default function ImportConvert({ onUseThis }: ImportConvertProps) { export default function ImportConvert({ onUseThis }: ImportConvertProps) {
const [selectedFile, setSelectedFile] = useState<File | null>(null); const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isSvgMode, setIsSvgMode] = useState(false); const [_isSvgMode, setIsSvgMode] = useState(false);
const [selectedPreset, setSelectedPreset] = useState('sign'); const [selectedPreset, setSelectedPreset] = useState('sign');
const [presetConfig, setPresetConfig] = useState<PresetConfig | null>(null); const [presetConfig, setPresetConfig] = useState<PresetConfig | null>(null);
const [currentParams, setCurrentParams] = useState<Record<string, unknown>>({ const [currentParams, setCurrentParams] = useState<Record<string, unknown>>({

View file

@ -1,4 +1,4 @@
/// <reference types="vitest" /> /// <reference types="vitest/config" />
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'