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:
parent
62c866be84
commit
fa4c765860
9 changed files with 837 additions and 6 deletions
|
|
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
33
app/src/types/opentype.d.ts
vendored
Normal 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;
|
||||||
|
}
|
||||||
401
app/src/utils/__tests__/exportService.test.ts
Normal file
401
app/src/utils/__tests__/exportService.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
289
app/src/utils/exportService.ts
Normal file
289
app/src/utils/exportService.ts
Normal 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, '&')
|
||||||
|
.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 ` <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);
|
||||||
|
}
|
||||||
|
|
@ -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>>({
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue