Fix SVG export: preserve original structure, no stray elements
- Single-element SVGs: id/label/transform directly on the element (no <g>) - SVGs with <g> group (dpm, diverter): keep the group, put id/label on it - Multi-child SVGs without group (beacon): wrap in <g> with id/label - Programmatic shapes (induction, curved, spur): single <path> with id Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c29d9266c
commit
8336018042
@ -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-<g>) elements from an SVG element tree,
|
||||
* composing transforms as we descend through <g> 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[] = [
|
||||
'<?xml version="1.0" encoding="UTF-8" standalone="no"?>',
|
||||
@ -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(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000"${t ? ` transform="${t}"` : ''} />`);
|
||||
lines.push(` <path ${idAttr} d="${d}" fill="#000000"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
|
||||
} 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(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${t ? ` transform="${t}"` : ''} />`);
|
||||
lines.push(` <path ${idAttr} d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
|
||||
} 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(` <path id="${label}" inkscape:label="${label}" d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${t ? ` transform="${t}"` : ''} />`);
|
||||
lines.push(` <path ${idAttr} d="${d}" fill="#000000" stroke="#000000" stroke-width="0.5"${outerTransform ? ` transform="${outerTransform}"` : ''} />`);
|
||||
} 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)}`);
|
||||
if (children.length === 1 && children[0].tagName === 'g') {
|
||||
// SVG has a <g> 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(` <g ${idAttr} transform="${fullTransform}">`);
|
||||
lines.push(` ${innerContent}`);
|
||||
lines.push(' </g>');
|
||||
} 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 {
|
||||
lines.push(` ${serializeLeaf(leaf.el, leaf.transform)}`);
|
||||
}
|
||||
// Multiple children without a group — wrap in <g> with id/label
|
||||
const innerContent = serializeChildren(svgEl);
|
||||
lines.push(` <g ${idAttr} transform="${baseTransform}">`);
|
||||
lines.push(` ${innerContent}`);
|
||||
lines.push(' </g>');
|
||||
}
|
||||
} 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 <g> 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(` <path${idAttrs(isFirst)} d="${d}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}"${tAttr(outerTransform)} />`);
|
||||
isFirst = false;
|
||||
const points = waypoints.map(wp => `${ox + wp.x},${oy + wp.y}`).join(' ');
|
||||
parts.push(` <polyline points="${points}" fill="none" stroke="#000000" stroke-width="${EPC_CONFIG.lineWidth}" />`);
|
||||
}
|
||||
|
||||
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(` <g transform="${fullT}">`);
|
||||
parts.push(` ${serializeChildren(g)}`);
|
||||
parts.push(` </g>`);
|
||||
} else {
|
||||
parts.push(` <g transform="${iconTransform}">`);
|
||||
parts.push(` ${serializeChildren(svgEl)}`);
|
||||
parts.push(` </g>`);
|
||||
}
|
||||
} 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(` <path${idAttrs(isFirst)} d="${d}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="${t}" />`);
|
||||
isFirst = false;
|
||||
parts.push(` <rect x="${-lb.w}" y="${-lb.h / 2}" width="${lb.w}" height="${lb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${p0x},${p0y}) rotate(${lAngle.toFixed(2)})" />`);
|
||||
}
|
||||
|
||||
// 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(` <path${idAttrs(isFirst)} d="${d}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="${rTransform}" />`);
|
||||
parts.push(` <rect x="0" y="${-rb.h / 2}" width="${rb.w}" height="${rb.h}" fill="#aaaaaa" stroke="#000000" stroke-width="0.3" transform="translate(${plx},${ply}) rotate(${rAngle.toFixed(2)})" />`);
|
||||
}
|
||||
|
||||
lines.push(` <g id="${label}" inkscape:label="${label}"${tAttr}>`);
|
||||
lines.push(parts.join('\n'));
|
||||
lines.push(' </g>');
|
||||
}
|
||||
|
||||
export function loadLayoutJSON(file: File): Promise<void> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user