diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts index 445357a..945bc98 100644 --- a/svelte-app/src/lib/export.ts +++ b/svelte-app/src/lib/export.ts @@ -3,47 +3,6 @@ import { isEpcType, isInductionType, isSpurType, isCurvedType, EPC_CONFIG, INDUC import { deserializeSymbol } from './serialization.js'; import type { PlacedSymbol } from './types.js'; -/** Recursively collect all leaf (non-) elements from an SVG element tree, - * composing transforms as we descend through nodes. */ -function collectLeaves(el: Element, parentTransform: string): { el: Element; transform: string }[] { - const results: { el: Element; transform: string }[] = []; - for (const child of Array.from(el.children)) { - const childTransform = child.getAttribute('transform') || ''; - const composed = [parentTransform, childTransform].filter(Boolean).join(' '); - if (child.tagName === 'g') { - // Recurse into groups, composing their transform - results.push(...collectLeaves(child, composed)); - } else { - results.push({ el: child, transform: composed }); - } - } - return results; -} - -/** Serialize an element to string, optionally overriding its transform, - * and stripping the default xmlns that XMLSerializer adds. */ -function serializeLeaf(el: Element, transform: string, id?: string, inkscapeLabel?: string): string { - const clone = el.cloneNode(true) as Element; - if (transform) { - clone.setAttribute('transform', transform); - } else { - clone.removeAttribute('transform'); - } - if (id) { - clone.setAttribute('id', id); - } - // inkscape:label via setAttributeNS won't serialize correctly with XMLSerializer, - // so we inject it as a string afterwards - let s = new XMLSerializer().serializeToString(clone) - .replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, ''); - if (inkscapeLabel) { - // Inject inkscape:label right after the tag name - const firstSpace = s.indexOf(' '); - s = s.slice(0, firstSpace) + ` inkscape:label="${inkscapeLabel}"` + s.slice(firstSpace); - } - return s; -} - function downloadBlob(blob: Blob, filename: string) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -53,6 +12,14 @@ function downloadBlob(blob: Blob, filename: string) { URL.revokeObjectURL(url); } +/** Serialize child elements of an SVG, stripping xmlns added by XMLSerializer */ +function serializeChildren(parent: Element): string { + return Array.from(parent.children) + .map(el => new XMLSerializer().serializeToString(el) + .replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, '')) + .join('\n '); +} + export async function exportSVG() { const lines: string[] = [ '', @@ -68,6 +35,7 @@ export async function exportSVG() { const cx = sym.x + sym.w / 2; const cy = sym.y + sym.h / 2; const label = sym.label || sym.name; + const idAttr = `id="${label}" inkscape:label="${label}"`; // Build outer transform (rotation + mirror) const outerParts: string[] = []; @@ -76,16 +44,14 @@ export async function exportSVG() { const outerTransform = outerParts.join(' '); if (isEpcType(sym.symbolId)) { - // EPC: polyline + icon + right box — emit flat elements - await emitEpcFlat(lines, sym as PlacedSymbol, label, outerTransform); + await emitEpc(lines, sym as PlacedSymbol, label, outerTransform); } else if (isInductionType(sym.symbolId)) { const hw = INDUCTION_CONFIG.headWidth; const stripTopY = sym.y + sym.h * INDUCTION_CONFIG.stripTopFrac; const stripBottomY = sym.y + sym.h * INDUCTION_CONFIG.stripBottomFrac; const pts = INDUCTION_CONFIG.arrowPoints.map(([xf, yf]) => [sym.x + xf * hw, sym.y + yf * sym.h] as const); const d = `M ${sym.x + sym.w},${stripTopY} L ${pts[0][0]},${stripTopY} ${pts.map(([px, py]) => `L ${px},${py}`).join(' ')} L ${pts[5][0]},${stripBottomY} L ${sym.x + sym.w},${stripBottomY} Z`; - const t = outerTransform || undefined; - lines.push(` `); + lines.push(` `); } else if (isCurvedType(sym.symbolId)) { const angle = sym.curveAngle || 90; const { arcCx, arcCy, outerR, innerR } = getCurveGeometry(sym.symbolId, sym.x, sym.y, sym.w, sym.h); @@ -102,15 +68,13 @@ export async function exportSVG() { `A ${innerR},${innerR} 0 ${largeArc},1 ${arcCx + innerR},${arcCy}`, 'Z', ].join(' '); - const t = outerTransform || undefined; - lines.push(` `); + lines.push(` `); } else if (isSpurType(sym.symbolId)) { const w2 = sym.w2 ?? sym.w; const d = `M ${sym.x},${sym.y} L ${sym.x + w2},${sym.y} L ${sym.x + sym.w},${sym.y + sym.h} L ${sym.x},${sym.y + sym.h} Z`; - const t = outerTransform || undefined; - lines.push(` `); + lines.push(` `); } else { - // Regular SVG symbol — flatten all children + // Regular SVG symbol try { const svgText = await (await fetch(sym.file)).text(); const doc = new DOMParser().parseFromString(svgText, 'image/svg+xml'); @@ -126,25 +90,40 @@ export async function exportSVG() { const sx = sym.w / vbW; const sy = sym.h / vbH; - // Base transform: position + scale from viewBox + // Base positioning transform let baseTransform = `translate(${sym.x},${sym.y}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; - if (mirrored) { - baseTransform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${baseTransform}`; - } - if (rot) { - baseTransform = `rotate(${rot},${cx},${cy}) ${baseTransform}`; - } + if (mirrored) baseTransform = `translate(${cx},0) scale(-1,1) translate(${-cx},0) ${baseTransform}`; + if (rot) baseTransform = `rotate(${rot},${cx},${cy}) ${baseTransform}`; - // Collect all leaf elements with composed transforms - const leaves = collectLeaves(svgEl, baseTransform); + const children = Array.from(svgEl.children); - for (let i = 0; i < leaves.length; i++) { - const leaf = leaves[i]; - if (i === 0) { - lines.push(` ${serializeLeaf(leaf.el, leaf.transform, label, label)}`); - } else { - lines.push(` ${serializeLeaf(leaf.el, leaf.transform)}`); - } + if (children.length === 1 && children[0].tagName === 'g') { + // SVG has a wrapper — keep it, put id/label on it, compose transforms + const g = children[0]; + const gTransform = g.getAttribute('transform') || ''; + const fullTransform = gTransform ? `${baseTransform} ${gTransform}` : baseTransform; + const innerContent = serializeChildren(g); + lines.push(` `); + lines.push(` ${innerContent}`); + lines.push(' '); + } else if (children.length === 1) { + // Single element, no group — put id/label/transform directly on it + const el = children[0].cloneNode(true) as Element; + const elTransform = el.getAttribute('transform'); + el.setAttribute('transform', elTransform ? `${baseTransform} ${elTransform}` : baseTransform); + el.setAttribute('id', label); + let s = new XMLSerializer().serializeToString(el) + .replace(/ xmlns="http:\/\/www\.w3\.org\/2000\/svg"/g, ''); + // Inject inkscape:label after the tag name + const firstSpace = s.indexOf(' '); + s = s.slice(0, firstSpace) + ` inkscape:label="${label}"` + s.slice(firstSpace); + lines.push(` ${s}`); + } else { + // Multiple children without a group — wrap in with id/label + const innerContent = serializeChildren(svgEl); + lines.push(` `); + lines.push(` ${innerContent}`); + lines.push(' '); } } catch (err) { console.error('Failed to embed symbol:', sym.name, err); @@ -156,24 +135,18 @@ export async function exportSVG() { downloadBlob(new Blob([lines.join('\n')], { type: 'image/svg+xml' }), 'test_view.svg'); } -/** Emit EPC symbol elements flat (no group wrapper) */ -async function emitEpcFlat(lines: string[], sym: PlacedSymbol, label: string, outerTransform: string) { +/** Emit EPC symbol — polyline + icon + right box, wrapped in with id/label */ +async function emitEpc(lines: string[], sym: PlacedSymbol, label: string, outerTransform: string) { const waypoints = sym.epcWaypoints || EPC_CONFIG.defaultWaypoints; const ox = sym.x; const oy = sym.y; - let isFirst = true; + const parts: string[] = []; + const tAttr = outerTransform ? ` transform="${outerTransform}"` : ''; - const idAttrs = (addId: boolean) => { - if (!addId) return ''; - return ` id="${label}" inkscape:label="${label}"`; - }; - const tAttr = (t: string) => t ? ` transform="${t}"` : ''; - - // Polyline as path + // Polyline if (waypoints.length >= 2) { - const d = 'M ' + waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' L '); - lines.push(` `); - isFirst = false; + const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' '); + parts.push(` `); } if (waypoints.length >= 2) { @@ -191,22 +164,23 @@ async function emitEpcFlat(lines: string[], sym: PlacedSymbol, label: string, ou const [vbX, vbY, vbW, vbH] = vb ? vb.split(/[\s,]+/).map(Number) : [0, 0, lb.w, lb.h]; const sx = lb.w / vbW; const sy = lb.h / vbH; + const iconTransform = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; - let iconBase = `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)}) translate(${-lb.w},${-lb.h / 2}) scale(${sx.toFixed(6)},${sy.toFixed(6)}) translate(${-vbX},${-vbY})`; - if (outerTransform) iconBase = `${outerTransform} ${iconBase}`; - - const leaves = collectLeaves(svgEl, iconBase); - for (const leaf of leaves) { - lines.push(` ${serializeLeaf(leaf.el, leaf.transform, isFirst ? label : undefined, isFirst ? label : undefined)}`); - isFirst = false; + const children = Array.from(svgEl.children); + if (children.length === 1 && children[0].tagName === 'g') { + const g = children[0]; + const gT = g.getAttribute('transform') || ''; + const fullT = gT ? `${iconTransform} ${gT}` : iconTransform; + parts.push(` `); + parts.push(` ${serializeChildren(g)}`); + parts.push(` `); + } else { + parts.push(` `); + parts.push(` ${serializeChildren(svgEl)}`); + parts.push(` `); } } catch { - const t = outerTransform - ? `${outerTransform} translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})` - : `translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})`; - const d = `M ${-lb.w},${-lb.h / 2} L 0,${-lb.h / 2} L 0,${lb.h / 2} L ${-lb.w},${lb.h / 2} Z`; - lines.push(` `); - isFirst = false; + parts.push(` `); } // Right box @@ -216,12 +190,12 @@ async function emitEpcFlat(lines: string[], sym: PlacedSymbol, label: string, ou const ppx = ox + prev.x, ppy = oy + prev.y; const rAngle = Math.atan2(ply - ppy, plx - ppx) * 180 / Math.PI; const rb = EPC_CONFIG.rightBox; - const rTransform = outerTransform - ? `${outerTransform} translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})` - : `translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})`; - const d = `M 0,${-rb.h / 2} L ${rb.w},${-rb.h / 2} L ${rb.w},${rb.h / 2} L 0,${rb.h / 2} Z`; - lines.push(` `); + parts.push(` `); } + + lines.push(` `); + lines.push(parts.join('\n')); + lines.push(' '); } export function loadLayoutJSON(file: File): Promise {