diff --git a/svelte-app/src/lib/canvas/renderer.ts b/svelte-app/src/lib/canvas/renderer.ts
index febadf3..2114b3f 100644
--- a/svelte-app/src/lib/canvas/renderer.ts
+++ b/svelte-app/src/lib/canvas/renderer.ts
@@ -522,6 +522,72 @@ function drawSymbolBody(ctx: CanvasRenderingContext2D, sym: PlacedSymbol): boole
return true;
}
+/** Parse a conveyance label like "UL17_22_VFD" into display lines.
+ * Strips known suffixes (_VFD, _VFD1, etc.), splits prefix from numbers,
+ * replaces underscores with dashes in the number part. */
+function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
+ // Strip known device suffixes
+ let core = label.replace(/_?VFD\d*$/i, '');
+ if (core === label) core = label; // no suffix found, use as-is
+
+ // Split into letter prefix and rest
+ const m = core.match(/^([A-Za-z]+)(.*)$/);
+ if (m) {
+ const prefix = m[1];
+ const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
+ if (nums) {
+ return { lines: [prefix, nums], stripped: [nums] };
+ }
+ return { lines: [prefix], stripped: [prefix] };
+ }
+ return { lines: [core], stripped: [core] };
+}
+
+/** Draw label inside a conveyance symbol — black bold text, auto-sized to fit */
+function drawConveyanceLabel(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
+ if (!sym.label) return;
+ const { lines, stripped } = parseConveyanceLabel(sym.label);
+ const pad = 2;
+ const availW = sym.w - pad * 2;
+ const availH = sym.h - pad * 2;
+ const cx = sym.x + sym.w / 2;
+ const cy = sym.y + sym.h / 2;
+
+ ctx.fillStyle = '#000000';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+
+ // Try full lines first, then stripped (prefix removed) if it doesn't fit
+ for (const tryLines of [lines, stripped]) {
+ // Target font size: 14px, but scale down to fit
+ let fontSize = 14;
+ const lineCount = tryLines.length;
+ const totalTextH = () => fontSize * lineCount + (lineCount - 1) * 1;
+
+ // Scale down if too tall
+ while (totalTextH() > availH && fontSize > 4) fontSize -= 0.5;
+
+ // Scale down if too wide
+ ctx.font = `bold ${fontSize}px Arial`;
+ let maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
+ while (maxW > availW && fontSize > 4) {
+ fontSize -= 0.5;
+ ctx.font = `bold ${fontSize}px Arial`;
+ maxW = Math.max(...tryLines.map(l => ctx.measureText(l).width));
+ }
+
+ if (fontSize >= 4) {
+ ctx.font = `bold ${fontSize}px Arial`;
+ const lineH = fontSize;
+ const startY = cy - (lineCount - 1) * lineH / 2;
+ for (let i = 0; i < tryLines.length; i++) {
+ ctx.fillText(tryLines[i], cx, startY + i * lineH);
+ }
+ return;
+ }
+ }
+}
+
function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
const cx = sym.x + sym.w / 2;
const isSelected = layout.selectedIds.has(sym.id);
@@ -576,20 +642,26 @@ function drawSymbolOverlays(ctx: CanvasRenderingContext2D, sym: PlacedSymbol) {
strokeOutline(ctx, sym, 0);
}
- // Label: centered inside symbol if large enough, above if too small
+ // Label rendering
if (sym.label) {
- ctx.fillStyle = THEME.label.color;
- ctx.font = THEME.label.font;
- const metrics = ctx.measureText(sym.label);
- const textH = 3; // approximate font height
- if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) {
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- ctx.fillText(sym.label, cx, sym.y + sym.h / 2);
+ const isConveyance = isRectConveyanceType(sym.symbolId) || isExtendoType(sym.symbolId)
+ || isCurvedType(sym.symbolId) || isSpurType(sym.symbolId) || isInductionType(sym.symbolId);
+ if (isConveyance) {
+ drawConveyanceLabel(ctx, sym);
} else {
- ctx.textAlign = 'center';
- ctx.textBaseline = 'bottom';
- ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
+ ctx.fillStyle = THEME.label.color;
+ ctx.font = THEME.label.font;
+ const metrics = ctx.measureText(sym.label);
+ const textH = 3;
+ if (sym.w >= metrics.width + 4 && sym.h >= textH + 4) {
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillText(sym.label, cx, sym.y + sym.h / 2);
+ } else {
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'bottom';
+ ctx.fillText(sym.label, cx, sym.y + THEME.label.offsetY);
+ }
}
}
}
diff --git a/svelte-app/src/lib/export.ts b/svelte-app/src/lib/export.ts
index a4fd85c..aa6a6bf 100644
--- a/svelte-app/src/lib/export.ts
+++ b/svelte-app/src/lib/export.ts
@@ -3,6 +3,36 @@ import { isEpcType, isInductionType, isSpurType, isCurvedType, isRectConveyanceT
import { deserializeSymbol } from './serialization.js';
import type { PlacedSymbol } from './types.js';
+/** Parse conveyance label into display lines — same logic as renderer */
+function parseConveyanceLabel(label: string): { lines: string[]; stripped: string[] } {
+ let core = label.replace(/_?VFD\d*$/i, '');
+ if (core === label) core = label;
+ const m = core.match(/^([A-Za-z]+)(.*)$/);
+ if (m) {
+ const prefix = m[1];
+ const nums = m[2].replace(/_/g, '-').replace(/^-/, '');
+ if (nums) return { lines: [prefix, nums], stripped: [nums] };
+ return { lines: [prefix], stripped: [prefix] };
+ }
+ return { lines: [core], stripped: [core] };
+}
+
+/** Emit conveyance label text element inside the shape */
+function emitConveyanceLabel(lines: string[], sym: PlacedSymbol, outerTransform: string) {
+ if (!sym.label) return;
+ const { lines: textLines } = parseConveyanceLabel(sym.label);
+ const cx = sym.x + sym.w / 2;
+ const cy = sym.y + sym.h / 2;
+ const fontSize = Math.min(14, (sym.h - 4) / textLines.length);
+ if (fontSize < 4) return;
+ const lineH = fontSize;
+ const startY = cy - (textLines.length - 1) * lineH / 2;
+ const tAttr = outerTransform ? ` transform="${outerTransform}"` : '';
+ for (let i = 0; i < textLines.length; i++) {
+ lines.push(` ${textLines[i]}`);
+ }
+}
+
function downloadBlob(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
@@ -65,6 +95,7 @@ export async function exportSVG() {
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`;
lines.push(` `);
+ emitConveyanceLabel(lines, sym as PlacedSymbol, 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);
@@ -82,12 +113,15 @@ export async function exportSVG() {
'Z',
].join(' ');
lines.push(` `);
+ emitConveyanceLabel(lines, sym as PlacedSymbol, 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`;
lines.push(` `);
+ emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isRectConveyanceType(sym.symbolId)) {
lines.push(` `);
+ emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isExtendoType(sym.symbolId)) {
const bracketW = 10.6 / 31.07 * 73;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;
@@ -107,6 +141,7 @@ export async function exportSVG() {
];
const d = `M ${pts[0][0]},${pts[0][1]} ` + pts.slice(1).map(p => `L ${p[0]},${p[1]}`).join(' ') + ' Z';
lines.push(` `);
+ emitConveyanceLabel(lines, sym as PlacedSymbol, outerTransform);
} else if (isPhotoeyeType(sym.symbolId)) {
const { leftCap, rightCap } = PHOTOEYE_CONFIG;
const x = sym.x, y = sym.y, w = sym.w, h = sym.h;