]*>/gi, '\n');
+
+ // Loop to handle nested/split tags (e.g.
ipt>)
+ let prev = '';
+ while (prev !== result) {
+ prev = result;
+ result = result.replace(/<[^>]*>/g, '');
+ }
+
+ return result
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/ /g, ' ')
+ .replace(/&/g, '&')
+ .replace(/\n{2,}/g, '\n')
+ .trim();
+}
+
+function normalizeArray(val: unknown): Array> {
+ if (!val) return [];
+ if (Array.isArray(val)) return val;
+ return [val as Record];
+}
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts
new file mode 100644
index 000000000..0ac57b4b7
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.test.ts
@@ -0,0 +1,47 @@
+import { describe, it, expect } from 'vitest';
+import { detectSvgFormat } from './format-detector';
+
+describe('detectSvgFormat', () => {
+ it('detects draw.io SVG with mxGraphModel', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('drawio');
+ });
+
+ it('detects draw.io SVG with mxfile marker', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('drawio');
+ });
+
+ it('detects draw.io SVG with content attribute containing mxCell', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('drawio');
+ });
+
+ it('returns generic for plain SVG', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('generic');
+ });
+
+ it('returns generic for SVG with no diagram metadata', () => {
+ const svg = `
+`;
+ expect(detectSvgFormat(svg)).toBe('generic');
+ });
+
+ it('returns generic when mxGraphModel appears only in a text label', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('generic');
+ });
+
+ it('detects draw.io when mxGraphModel is a proper XML tag', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('drawio');
+ });
+
+ it('detects draw.io from percent-encoded mxGraphModel in content attribute', () => {
+ const svg = '';
+ expect(detectSvgFormat(svg)).toBe('drawio');
+ });
+});
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts
new file mode 100644
index 000000000..a6850a135
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/format-detector.ts
@@ -0,0 +1,13 @@
+import type { SvgFormat } from './types';
+
+export function detectSvgFormat(svgContent: string): SvgFormat {
+ if (
+ /]/.test(svgContent) ||
+ svgContent.includes('%3CmxGraphModel') ||
+ svgContent.includes('mxfile') ||
+ (svgContent.includes('content="') && svgContent.includes('mxCell'))
+ ) {
+ return 'drawio';
+ }
+ return 'generic';
+}
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts
new file mode 100644
index 000000000..f244c1dcb
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.test.ts
@@ -0,0 +1,320 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { join } from 'path';
+import { parseGenericSvg } from './generic-svg-parser';
+
+const fixture = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf-8');
+
+describe('parseGenericSvg', () => {
+ it('extracts nodes from grouped shapes with text', () => {
+ const svg = fixture('generic-simple.svg');
+ const result = parseGenericSvg(svg);
+
+ expect(result.sourceFormat).toBe('generic');
+ expect(result.nodes.length).toBeGreaterThanOrEqual(3);
+ });
+
+ it('extracts labels from text elements', () => {
+ const svg = fixture('generic-simple.svg');
+ const result = parseGenericSvg(svg);
+
+ const labels = result.nodes.map(n => n.label);
+ expect(labels).toContain('API Service');
+ expect(labels).toContain('Payment System');
+ expect(labels).toContain('User');
+ });
+
+ it('classifies shapes correctly', () => {
+ const svg = fixture('generic-simple.svg');
+ const result = parseGenericSvg(svg);
+
+ const apiService = result.nodes.find(n => n.label === 'API Service');
+ const user = result.nodes.find(n => n.label === 'User');
+
+ expect(apiService?.shapeHint).toBe('rounded-rectangle');
+ expect(user?.shapeHint).toBe('ellipse');
+ });
+
+ it('extracts geometry from shape attributes', () => {
+ const svg = fixture('generic-simple.svg');
+ const result = parseGenericSvg(svg);
+
+ const apiService = result.nodes.find(n => n.label === 'API Service');
+ expect(apiService?.geometry).toEqual({ x: 50, y: 50, width: 180, height: 70 });
+ });
+
+ it('detects edges from line elements', () => {
+ const svg = fixture('generic-simple.svg');
+ const result = parseGenericSvg(svg);
+
+ expect(result.edges.length).toBeGreaterThanOrEqual(1);
+ });
+
+ it('returns empty for SVG with no shapes', () => {
+ const svg = '';
+ const result = parseGenericSvg(svg);
+ expect(result.nodes).toHaveLength(0);
+ });
+
+ it('detects containment and converts child coordinates to parent-relative', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ const child = result.nodes.find(n => n.label === 'Child');
+ const container = result.nodes.find(n => n.label === 'Container');
+ expect(child?.parentId).toBe(container?.id);
+ expect(child?.geometry).toEqual({ x: 40, y: 40, width: 100, height: 60 });
+ });
+
+ it('handles tspan text elements', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.label).toBe('Multi Line');
+ });
+
+ it('assigns IDs from group id attribute', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes[0]?.id).toBe('my-custom-id');
+ });
+
+ it('generates fallback IDs when no id attribute', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes[0]?.id).toMatch(/^node-\d+$/);
+ });
+
+ it('handles circle shapes', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.shapeHint).toBe('ellipse');
+ expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 60, width: 80, height: 80 });
+ });
+
+ it('ignores shapes smaller than minimum size', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.label).toBe('Big');
+ });
+
+ it('detects polyline edges between nodes', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0]?.sourceId).toBe('a');
+ expect(result.edges[0]?.targetId).toBe('b');
+ });
+
+ it('does not create edge when endpoints are too far from nodes', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.edges).toHaveLength(0);
+ });
+
+ it('handles standalone shapes not in groups with nearby text', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.label).toBe('Standalone');
+ });
+
+ it('extracts nodes from nested groups', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ const labels = result.nodes.map(n => n.label);
+ expect(labels).toContain('Outer');
+ expect(labels).toContain('Inner');
+ });
+
+ it('assigns child to nearest container, not outermost', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ const leaf = result.nodes.find(n => n.label === 'Leaf');
+ const mid = result.nodes.find(n => n.label === 'Mid');
+ expect(leaf?.parentId).toBe(mid?.id);
+ });
+
+ it('applies translate transform on group to child geometry', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 110, y: 210, width: 150, height: 60 });
+ });
+
+ it('accumulates nested translate transforms', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 60, y: 110, width: 150, height: 60 });
+ });
+
+ it('ignores non-translate transforms gracefully', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 10, y: 10, width: 150, height: 60 });
+ });
+
+ it('applies scale() transform to geometry', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 20, width: 200, height: 100 });
+ });
+
+ it('applies scale(sx, sy) with different axes', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 20, y: 30, width: 200, height: 150 });
+ });
+
+ it('extracts scale from matrix() transform', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.geometry).toEqual({ x: 70, y: 130, width: 200, height: 150 });
+ });
+
+ it('detects edges from line inside a translated group', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0]?.sourceId).toBe('a');
+ expect(result.edges[0]?.targetId).toBe('b');
+ });
+
+ it('detects polyline edges inside a translated group', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.edges).toHaveLength(1);
+ expect(result.edges[0]?.sourceId).toBe('a');
+ expect(result.edges[0]?.targetId).toBe('b');
+ });
+
+ it('assigns nearest text to standalone shape, not first within radius', () => {
+ const svg = ``;
+ const result = parseGenericSvg(svg);
+
+ expect(result.nodes).toHaveLength(1);
+ expect(result.nodes[0]?.label).toBe('Near Label');
+ });
+});
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts
new file mode 100644
index 000000000..b6d8ec47a
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/generic-svg-parser.ts
@@ -0,0 +1,409 @@
+import { parse as parseSvg, type ElementNode, type TextNode } from 'svg-parser';
+import type { ParsedSvgGraph, SvgNode, SvgEdge, ShapeHint, SvgNodeGeometry } from './types';
+
+type HastNode = ElementNode | TextNode;
+
+const MIN_SHAPE_SIZE = 20;
+const EDGE_PROXIMITY_THRESHOLD = 15;
+
+export function parseGenericSvg(svgContent: string): ParsedSvgGraph {
+ const root = parseSvg(svgContent);
+ const svg = findElement(root.children, 'svg');
+ if (!svg) return { nodes: [], edges: [], sourceFormat: 'generic' };
+
+ const nodes: SvgNode[] = [];
+ const edges: SvgEdge[] = [];
+
+ const svgTransform = parseTransform(String(svg.properties.transform ?? ''));
+ extractNodesFromElement(svg, nodes, svgTransform);
+ extractEdgesFromElement(svg, nodes, edges, svgTransform);
+ detectContainment(nodes);
+
+ return { nodes, edges, sourceFormat: 'generic' };
+}
+
+function findElement(children: HastNode[], tagName: string): ElementNode | null {
+ for (const child of children) {
+ if (child.type !== 'element') continue;
+ if (child.tagName === tagName) return child;
+ const found = findElement(child.children, tagName);
+ if (found) return found;
+ }
+ return null;
+}
+
+interface Transform2D {
+ tx: number;
+ ty: number;
+ sx: number;
+ sy: number;
+}
+
+const IDENTITY_TRANSFORM: Transform2D = { tx: 0, ty: 0, sx: 1, sy: 1 };
+
+function composeTransforms(outer: Transform2D, inner: Transform2D): Transform2D {
+ return {
+ sx: outer.sx * inner.sx,
+ sy: outer.sy * inner.sy,
+ tx: outer.sx * inner.tx + outer.tx,
+ ty: outer.sy * inner.ty + outer.ty,
+ };
+}
+
+function applyTransformToGeometry(geo: SvgNodeGeometry, accTransform: Transform2D, localTransform: Transform2D): void {
+ const localX = localTransform.sx * geo.x + localTransform.tx;
+ const localY = localTransform.sy * geo.y + localTransform.ty;
+ geo.x = accTransform.sx * localX + accTransform.tx;
+ geo.y = accTransform.sy * localY + accTransform.ty;
+ geo.width = Math.abs(accTransform.sx * localTransform.sx) * geo.width;
+ geo.height = Math.abs(accTransform.sy * localTransform.sy) * geo.height;
+}
+
+function extractNodesFromElement(
+ element: ElementNode,
+ nodes: SvgNode[],
+ accTransform: Transform2D = IDENTITY_TRANSFORM
+): void {
+ const children = element.children;
+
+ for (const child of children) {
+ if (child.type !== 'element') continue;
+
+ if (child.tagName === 'g') {
+ const groupTransform = parseTransform(String(child.properties.transform ?? ''));
+ const childTransform = composeTransforms(accTransform, groupTransform);
+ const node = tryExtractNodeFromGroup(child, nodes.length, childTransform);
+ if (node) {
+ nodes.push(node);
+ }
+ extractNodesFromElement(child, nodes, childTransform);
+ }
+ }
+
+ const capturedBounds = nodes.map(n => n.geometry);
+ for (const child of children) {
+ if (child.type !== 'element') continue;
+ const tag = child.tagName;
+
+ if (tag === 'rect' || tag === 'ellipse' || tag === 'circle') {
+ const geo = getShapeGeometry(tag, child.properties);
+ if (!geo || geo.width < MIN_SHAPE_SIZE || geo.height < MIN_SHAPE_SIZE) continue;
+
+ const elTransform = parseTransform(String(child.properties.transform ?? ''));
+ applyTransformToGeometry(geo, accTransform, elTransform);
+
+ if (overlapsExisting(geo, capturedBounds)) continue;
+
+ const label = findNearbyTextInElement(element, geo);
+ const id = String(child.properties.id ?? `node-${nodes.length}`);
+ nodes.push({
+ id,
+ label: label ?? '',
+ shapeHint: classifyTag(tag, child.properties),
+ geometry: geo,
+ styleProps: {},
+ });
+ capturedBounds.push(geo);
+ }
+ }
+}
+
+function tryExtractNodeFromGroup(
+ g: ElementNode,
+ index: number,
+ accTransform: Transform2D
+): SvgNode | null {
+ let shapeGeo: SvgNodeGeometry | null = null;
+ let shapeHint: ShapeHint = 'unknown';
+ let label = '';
+
+ let shapeTransform: Transform2D = IDENTITY_TRANSFORM;
+
+ for (const child of g.children) {
+ if (child.type !== 'element') continue;
+ const tag = child.tagName;
+
+ if ((tag === 'rect' || tag === 'ellipse' || tag === 'circle') && !shapeGeo) {
+ shapeGeo = getShapeGeometry(tag, child.properties);
+ shapeHint = classifyTag(tag, child.properties);
+ shapeTransform = parseTransform(String(child.properties.transform ?? ''));
+ }
+
+ if (tag === 'text' && !label) {
+ label = extractTextContent(child);
+ }
+ }
+
+ if (!shapeGeo || shapeGeo.width < MIN_SHAPE_SIZE || shapeGeo.height < MIN_SHAPE_SIZE) {
+ return null;
+ }
+
+ applyTransformToGeometry(shapeGeo, accTransform, shapeTransform);
+
+ const id = String(g.properties.id ?? `node-${index}`);
+ return { id, label, shapeHint, geometry: shapeGeo, styleProps: {} };
+}
+
+function applyPoint(point: { x: number; y: number }, t: Transform2D): { x: number; y: number } {
+ return { x: t.sx * point.x + t.tx, y: t.sy * point.y + t.ty };
+}
+
+function extractEdgesFromElement(
+ element: ElementNode,
+ nodes: SvgNode[],
+ edges: SvgEdge[],
+ accTransform: Transform2D = IDENTITY_TRANSFORM
+): void {
+ for (const child of element.children) {
+ if (child.type !== 'element') continue;
+ const tag = child.tagName;
+ const props = child.properties;
+ const elTransform = composeTransforms(accTransform, parseTransform(String(props.transform ?? '')));
+
+ if (tag === 'line') {
+ const edge = tryMatchLine(props, nodes, edges.length, elTransform);
+ if (edge) edges.push(edge);
+ } else if (tag === 'polyline') {
+ const points = String(props.points ?? '');
+ const coords = points.split(/\s+/).map(p => p.split(',').map(Number));
+ if (coords.length >= 2) {
+ const start = applyPoint({ x: coords[0]![0]!, y: coords[0]![1]! }, elTransform);
+ const end = applyPoint({ x: coords[coords.length - 1]![0]!, y: coords[coords.length - 1]![1]! }, elTransform);
+ const source = findNearestNode(start, nodes);
+ const target = findNearestNode(end, nodes);
+ if (source && target && source !== target) {
+ edges.push({
+ id: String(props.id ?? `edge-${edges.length}`),
+ sourceId: source.id,
+ targetId: target.id,
+ });
+ }
+ }
+ } else if (tag === 'g') {
+ extractEdgesFromElement(child, nodes, edges, elTransform);
+ }
+ }
+}
+
+function tryMatchLine(props: Record, nodes: SvgNode[], index: number, transform: Transform2D = IDENTITY_TRANSFORM): SvgEdge | null {
+ const x1 = parseFloat(String(props.x1 ?? ''));
+ const y1 = parseFloat(String(props.y1 ?? ''));
+ const x2 = parseFloat(String(props.x2 ?? ''));
+ const y2 = parseFloat(String(props.y2 ?? ''));
+
+ if (isNaN(x1) || isNaN(y1) || isNaN(x2) || isNaN(y2)) return null;
+
+ const start = applyPoint({ x: x1, y: y1 }, transform);
+ const end = applyPoint({ x: x2, y: y2 }, transform);
+ const source = findNearestNode(start, nodes);
+ const target = findNearestNode(end, nodes);
+
+ if (!source || !target || source === target) return null;
+
+ return {
+ id: String(props.id ?? `edge-${index}`),
+ sourceId: source.id,
+ targetId: target.id,
+ };
+}
+
+function findNearestNode(point: { x: number; y: number }, nodes: SvgNode[]): SvgNode | null {
+ let closest: SvgNode | null = null;
+ let minDist = EDGE_PROXIMITY_THRESHOLD;
+
+ for (const node of nodes) {
+ const dist = distanceToNodeBorder(point, node.geometry);
+ if (dist < minDist) {
+ minDist = dist;
+ closest = node;
+ }
+ }
+
+ return closest;
+}
+
+function distanceToNodeBorder(point: { x: number; y: number }, geo: SvgNodeGeometry): number {
+ const { x, y, width, height } = geo;
+
+ if (point.x >= x && point.x <= x + width && point.y >= y && point.y <= y + height) {
+ return 0;
+ }
+
+ const nearestX = Math.max(x, Math.min(point.x, x + width));
+ const nearestY = Math.max(y, Math.min(point.y, y + height));
+
+ return Math.sqrt((point.x - nearestX) ** 2 + (point.y - nearestY) ** 2);
+}
+
+function detectContainment(nodes: SvgNode[]): void {
+ const byArea = [...nodes].sort((a, b) => {
+ const aArea = a.geometry.width * a.geometry.height;
+ const bArea = b.geometry.width * b.geometry.height;
+ return aArea - bArea;
+ });
+
+ for (const child of nodes) {
+ if (child.parentId) continue;
+ for (const parent of byArea) {
+ if (parent.id === child.id) continue;
+ if (parent.parentId === child.id) continue;
+ if (isFullyContained(child.geometry, parent.geometry)) {
+ child.parentId = parent.id;
+ break;
+ }
+ }
+ }
+
+ const absoluteGeo = new Map(nodes.map(n => [n.id, { ...n.geometry }]));
+ for (const child of nodes) {
+ if (!child.parentId) continue;
+ const parentGeo = absoluteGeo.get(child.parentId);
+ if (!parentGeo) continue;
+ child.geometry = {
+ ...child.geometry,
+ x: child.geometry.x - parentGeo.x,
+ y: child.geometry.y - parentGeo.y,
+ };
+ }
+}
+
+function isFullyContained(inner: SvgNodeGeometry, outer: SvgNodeGeometry): boolean {
+ const margin = 5;
+ return (
+ inner.x >= outer.x + margin &&
+ inner.y >= outer.y + margin &&
+ inner.x + inner.width <= outer.x + outer.width - margin &&
+ inner.y + inner.height <= outer.y + outer.height - margin
+ );
+}
+
+function parseTransform(transform: string | undefined): Transform2D {
+ if (!transform) return IDENTITY_TRANSFORM;
+
+ let tx = 0, ty = 0, sx = 1, sy = 1;
+
+ const scaleMatch = transform.match(/scale\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/);
+ if (scaleMatch) {
+ sx = parseFloat(scaleMatch[1]!);
+ sy = scaleMatch[2] ? parseFloat(scaleMatch[2]) : sx;
+ }
+
+ const translateMatch = transform.match(/translate\(\s*([-\d.]+)(?:[\s,]+([-\d.]+))?\s*\)/);
+ if (translateMatch) {
+ tx = parseFloat(translateMatch[1]!);
+ ty = translateMatch[2] ? parseFloat(translateMatch[2]) : 0;
+ }
+
+ const matrixMatch = transform.match(/matrix\(\s*([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)[\s,]+([-\d.]+)\s*\)/);
+ if (matrixMatch) {
+ const a = parseFloat(matrixMatch[1]!);
+ const b = parseFloat(matrixMatch[2]!);
+ const c = parseFloat(matrixMatch[3]!);
+ const d = parseFloat(matrixMatch[4]!);
+ tx = parseFloat(matrixMatch[5]!);
+ ty = parseFloat(matrixMatch[6]!);
+ // Extract scale from axis-aligned matrices (b ≈ 0, c ≈ 0)
+ if (Math.abs(b) < 0.001 && Math.abs(c) < 0.001) {
+ sx = a;
+ sy = d;
+ } else {
+ sx = Math.sqrt(a * a + b * b);
+ sy = Math.sqrt(c * c + d * d);
+ }
+ }
+
+ if (!scaleMatch && !translateMatch && !matrixMatch) return IDENTITY_TRANSFORM;
+
+ return { tx, ty, sx, sy };
+}
+
+function getShapeGeometry(tag: string, props: Record): SvgNodeGeometry | null {
+ switch (tag) {
+ case 'rect': {
+ const x = parseFloat(String(props.x ?? '0'));
+ const y = parseFloat(String(props.y ?? '0'));
+ const width = parseFloat(String(props.width ?? '0'));
+ const height = parseFloat(String(props.height ?? '0'));
+ if (width === 0 || height === 0) return null;
+ return { x, y, width, height };
+ }
+ case 'ellipse': {
+ const cx = parseFloat(String(props.cx ?? '0'));
+ const cy = parseFloat(String(props.cy ?? '0'));
+ const rx = parseFloat(String(props.rx ?? '0'));
+ const ry = parseFloat(String(props.ry ?? '0'));
+ if (rx === 0 || ry === 0) return null;
+ return { x: cx - rx, y: cy - ry, width: rx * 2, height: ry * 2 };
+ }
+ case 'circle': {
+ const cx = parseFloat(String(props.cx ?? '0'));
+ const cy = parseFloat(String(props.cy ?? '0'));
+ const r = parseFloat(String(props.r ?? '0'));
+ if (r === 0) return null;
+ return { x: cx - r, y: cy - r, width: r * 2, height: r * 2 };
+ }
+ default:
+ return null;
+ }
+}
+
+function classifyTag(tag: string, props: Record): ShapeHint {
+ switch (tag) {
+ case 'ellipse':
+ case 'circle':
+ return 'ellipse';
+ case 'rect': {
+ const rx = parseFloat(String(props.rx ?? '0'));
+ return rx > 0 ? 'rounded-rectangle' : 'rectangle';
+ }
+ default:
+ return 'unknown';
+ }
+}
+
+function extractTextContent(textEl: ElementNode): string {
+ const parts: string[] = [];
+
+ for (const child of textEl.children) {
+ if (child.type === 'text' && child.value) {
+ parts.push(child.value);
+ } else if (child.type === 'element' && child.tagName === 'tspan') {
+ const tspanText = extractTextContent(child);
+ if (tspanText) parts.push(tspanText);
+ }
+ }
+
+ return parts.join(' ').trim();
+}
+
+function findNearbyTextInElement(element: ElementNode, geo: SvgNodeGeometry): string | null {
+ const cx = geo.x + geo.width / 2;
+ const cy = geo.y + geo.height / 2;
+ const threshold = Math.max(geo.width, geo.height);
+
+ let bestLabel: string | null = null;
+ let bestDist = threshold;
+
+ for (const child of element.children) {
+ if (child.type !== 'element' || child.tagName !== 'text') continue;
+ const props = child.properties;
+ const tx = parseFloat(String(props.x ?? '0'));
+ const ty = parseFloat(String(props.y ?? '0'));
+ const dist = Math.sqrt((tx - cx) ** 2 + (ty - cy) ** 2);
+ if (dist < bestDist) {
+ bestDist = dist;
+ bestLabel = extractTextContent(child);
+ }
+ }
+ return bestLabel;
+}
+
+function overlapsExisting(geo: SvgNodeGeometry, existing: SvgNodeGeometry[]): boolean {
+ for (const e of existing) {
+ if (Math.abs(geo.x - e.x) < 2 && Math.abs(geo.y - e.y) < 2 &&
+ Math.abs(geo.width - e.width) < 2 && Math.abs(geo.height - e.height) < 2) {
+ return true;
+ }
+ }
+ return false;
+}
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/index.ts b/calm-plugins/vscode/src/extension/services/svg-import/index.ts
new file mode 100644
index 000000000..c23a39b2a
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/index.ts
@@ -0,0 +1,7 @@
+export { SvgImportService } from './svg-import-service';
+export { detectSvgFormat } from './format-detector';
+export { parseDrawioSvg } from './drawio-parser';
+export { parseGenericSvg } from './generic-svg-parser';
+export { buildCalmJson } from './calm-builder';
+export { mapShapeToNodeType } from './shape-mapper';
+export type { ParsedSvgGraph, SvgNode, SvgEdge, ImportResult, ShapeHint, SvgFormat } from './types';
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts
new file mode 100644
index 000000000..f9a356f06
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest';
+import { mapShapeToNodeType } from './shape-mapper';
+
+describe('mapShapeToNodeType', () => {
+ describe('shape-based mapping', () => {
+ it('maps cylinder to database', () => {
+ expect(mapShapeToNodeType('cylinder', 'Orders')).toBe('database');
+ });
+
+ it('maps person to actor', () => {
+ expect(mapShapeToNodeType('person', 'Admin')).toBe('actor');
+ });
+
+ it('maps cloud to ecosystem', () => {
+ expect(mapShapeToNodeType('cloud', 'AWS')).toBe('ecosystem');
+ });
+
+ it('maps rounded-rectangle to service', () => {
+ expect(mapShapeToNodeType('rounded-rectangle', 'Auth')).toBe('service');
+ });
+
+ it('maps rectangle to system', () => {
+ expect(mapShapeToNodeType('rectangle', 'Backend')).toBe('system');
+ });
+
+ it('maps unknown to system', () => {
+ expect(mapShapeToNodeType('unknown', 'Thing')).toBe('system');
+ });
+
+ it('maps document to data-asset', () => {
+ expect(mapShapeToNodeType('document', 'Report')).toBe('data-asset');
+ });
+ });
+
+ describe('label-based overrides', () => {
+ it('overrides shape for database keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'PostgreSQL Database')).toBe('database');
+ });
+
+ it('overrides shape for actor keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'End User')).toBe('actor');
+ });
+
+ it('overrides shape for webclient keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'Web App Frontend')).toBe('webclient');
+ });
+
+ it('overrides shape for network keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'Private VPC')).toBe('network');
+ });
+
+ it('overrides shape for ldap keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'Active Directory')).toBe('ldap');
+ });
+
+ it('overrides shape for ecosystem keywords', () => {
+ expect(mapShapeToNodeType('rectangle', 'Third Party API')).toBe('ecosystem');
+ });
+
+ it('does not override when no keyword matches', () => {
+ expect(mapShapeToNodeType('cylinder', 'Cache Layer')).toBe('database');
+ });
+
+ it('prefers database over actor when both keywords appear', () => {
+ expect(mapShapeToNodeType('rectangle', 'Customer Database')).toBe('database');
+ });
+
+ it('prefers network over actor for "Client VPC"', () => {
+ expect(mapShapeToNodeType('rectangle', 'Client VPC')).toBe('network');
+ });
+ });
+});
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts
new file mode 100644
index 000000000..52b61e5d1
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/shape-mapper.ts
@@ -0,0 +1,33 @@
+import type { ShapeHint } from './types';
+
+const SHAPE_TO_NODE_TYPE: Record = {
+ 'cylinder': 'database',
+ 'person': 'actor',
+ 'cloud': 'ecosystem',
+ 'ellipse': 'system',
+ 'hexagon': 'service',
+ 'diamond': 'service',
+ 'rectangle': 'system',
+ 'rounded-rectangle': 'service',
+ 'document': 'data-asset',
+ 'parallelogram': 'data-asset',
+ 'unknown': 'system',
+};
+
+const LABEL_PATTERNS: Array<[RegExp, string]> = [
+ [/\b(db|database|datastore|data.?store|storage|redis|postgres|mysql|mongo|dynamo|cassandra)\b/i, 'database'],
+ [/\b(browser|web.?app|frontend|ui|spa|portal)\b/i, 'webclient'],
+ [/\b(network|vpc|subnet|firewall|dmz|zone|vnet)\b/i, 'network'],
+ [/\b(ldap|active.?directory)\b/i, 'ldap'],
+ [/\b(ecosystem|external|third.?party|cloud|platform)\b/i, 'ecosystem'],
+ [/\b(user|actor|person|customer|client|operator)\b/i, 'actor'],
+];
+
+export function mapShapeToNodeType(shapeHint: ShapeHint, label: string): string {
+ // Label-based overrides take priority for strong signals
+ for (const [pattern, nodeType] of LABEL_PATTERNS) {
+ if (pattern.test(label)) return nodeType;
+ }
+
+ return SHAPE_TO_NODE_TYPE[shapeHint] ?? 'system';
+}
diff --git a/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts
new file mode 100644
index 000000000..2aad91b97
--- /dev/null
+++ b/calm-plugins/vscode/src/extension/services/svg-import/svg-import-service.test.ts
@@ -0,0 +1,160 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import * as vscode from 'vscode';
+import { SvgImportService } from './svg-import-service';
+
+const VALID_SVG = ``;
+
+const EMPTY_SVG = '';
+
+const SVG_URI = vscode.Uri.file('/workspace/diagram.svg');
+const OUTPUT_URI = vscode.Uri.file('/workspace/diagram.calm.json');
+
+function createMockDocument(content = '{}'): vscode.TextDocument {
+ return {
+ uri: vscode.Uri.file('/workspace/test.calm.json'),
+ getText: () => content,
+ positionAt: (offset: number) => ({ line: 0, character: offset }),
+ } as unknown as vscode.TextDocument;
+}
+
+function createMockOutputChannel(): vscode.OutputChannel {
+ return { appendLine: vi.fn() } as unknown as vscode.OutputChannel;
+}
+
+describe('SvgImportService', () => {
+ let service: SvgImportService;
+
+ beforeEach(() => {
+ service = new SvgImportService(createMockOutputChannel());
+ (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue('Import');
+ (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue([vscode.Uri.file('/test.svg')]);
+ (vscode.window as Record).showInformationMessage = vi.fn().mockResolvedValue(undefined);
+ (vscode.window as Record).showErrorMessage = vi.fn().mockResolvedValue(undefined);
+ (vscode.workspace as { fs: Record }).fs = {
+ readFile: vi.fn().mockResolvedValue(Buffer.from(VALID_SVG)),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ };
+ (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(true);
+ });
+
+ it('returns null when no document is open', async () => {
+ const result = await service.importSvgIntoDocument(undefined);
+ expect(result).toBeNull();
+ expect(vscode.window.showWarningMessage).toHaveBeenCalledWith('No CALM document is open.');
+ });
+
+ it('returns null when user cancels confirmation', async () => {
+ (vscode.window as Record).showWarningMessage = vi.fn().mockResolvedValue(undefined);
+ const result = await service.importSvgIntoDocument(createMockDocument());
+ expect(result).toBeNull();
+ });
+
+ it('returns null when user cancels file picker', async () => {
+ (vscode.window as Record).showOpenDialog = vi.fn().mockResolvedValue(undefined);
+ const result = await service.importSvgIntoDocument(createMockDocument());
+ expect(result).toBeNull();
+ });
+
+ it('returns null when SVG has no nodes', async () => {
+ (vscode.workspace as { fs: Record }).fs = {
+ readFile: vi.fn().mockResolvedValue(Buffer.from(EMPTY_SVG)),
+ writeFile: vi.fn().mockResolvedValue(undefined),
+ };
+ const result = await service.importSvgIntoDocument(createMockDocument());
+ expect(result).toBeNull();
+ });
+
+ it('successfully imports SVG and replaces document', async () => {
+ const result = await service.importSvgIntoDocument(createMockDocument());
+ expect(result).not.toBeNull();
+ const doc = JSON.parse(result!);
+ expect(doc.nodes).toHaveLength(1);
+ expect(vscode.window.showInformationMessage).toHaveBeenCalled();
+ });
+
+ it('returns null when applyEdit fails', async () => {
+ (vscode.workspace as Record).applyEdit = vi.fn().mockResolvedValue(false);
+ const result = await service.importSvgIntoDocument(createMockDocument());
+ expect(result).toBeNull();
+ expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
+ 'Failed to apply the imported CALM JSON to the document.'
+ );
+ });
+
+ it('returns null and shows error on parse failure', async () => {
+ const invalidSvg = '