From 3bc30db5b216cf359ea62fce0832703b7635f190 Mon Sep 17 00:00:00 2001 From: Kayshen-X Date: Thu, 19 Feb 2026 12:52:30 +0800 Subject: [PATCH] feat(chat): enhance chat streaming with 'thinking' state and improve SVG handling in toolbar - Add support for 'thinking' state in chat message streaming to indicate processing. - Update toolbar to parse SVG files into editable nodes, allowing for better integration of SVG graphics. - Refactor image handling to accommodate both SVG and raster images, ensuring proper placement and scaling on the canvas. - Improve error handling and user feedback during file uploads. --- server/api/ai/chat.ts | 28 +- src/components/editor/toolbar.tsx | 83 ++++-- src/components/panels/ai-chat-panel.tsx | 2 + src/routes/__root.tsx | 11 + src/services/ai/ai-prompts.ts | 28 +- src/services/ai/ai-types.ts | 2 +- src/utils/svg-parser.ts | 378 ++++++++++++++++++++++++ 7 files changed, 486 insertions(+), 46 deletions(-) create mode 100644 src/utils/svg-parser.ts diff --git a/server/api/ai/chat.ts b/server/api/ai/chat.ts index 4df1c1d22..19576c828 100644 --- a/server/api/ai/chat.ts +++ b/server/api/ai/chat.ts @@ -53,12 +53,14 @@ async function streamViaAnthropicSDK(apiKey: string, body: ChatBody, model?: str }) for await (const ev of messageStream) { - if ( - ev.type === 'content_block_delta' && - ev.delta.type === 'text_delta' - ) { - const data = JSON.stringify({ type: 'text', content: ev.delta.text }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + if (ev.type === 'content_block_delta') { + if (ev.delta.type === 'text_delta') { + const data = JSON.stringify({ type: 'text', content: ev.delta.text }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } else if (ev.delta.type === 'thinking_delta') { + const data = JSON.stringify({ type: 'thinking', content: '' }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } } } @@ -113,12 +115,14 @@ function streamViaAgentSDK(body: ChatBody, model?: string) { for await (const message of q) { if (message.type === 'stream_event') { const ev = message.event - if ( - ev.type === 'content_block_delta' && - ev.delta.type === 'text_delta' - ) { - const data = JSON.stringify({ type: 'text', content: ev.delta.text }) - controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + if (ev.type === 'content_block_delta') { + if (ev.delta.type === 'text_delta') { + const data = JSON.stringify({ type: 'text', content: ev.delta.text }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } else if (ev.delta.type === 'thinking_delta') { + const data = JSON.stringify({ type: 'thinking', content: '' }) + controller.enqueue(encoder.encode(`data: ${data}\n\n`)) + } } } else if (message.type === 'result') { if (message.subtype !== 'success') { diff --git a/src/components/editor/toolbar.tsx b/src/components/editor/toolbar.tsx index 4ae43923e..dec85cdd1 100644 --- a/src/components/editor/toolbar.tsx +++ b/src/components/editor/toolbar.tsx @@ -14,6 +14,7 @@ import { import ToolButton from './tool-button' import { useCanvasStore } from '@/stores/canvas-store' import { useDocumentStore, generateId } from '@/stores/document-store' +import { parseSvgToNodes } from '@/utils/svg-parser' import { useHistoryStore } from '@/stores/history-store' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' @@ -66,43 +67,71 @@ export default function Toolbar() { // Reset input so the same file can be re-selected e.target.value = '' - const reader = new FileReader() - reader.onload = () => { - const dataUrl = reader.result as string - const img = new Image() - img.onload = () => { + const isSvg = file.type === 'image/svg+xml' + + if (isSvg) { + // SVG → parse into editable path/shape nodes + const reader = new FileReader() + reader.onload = () => { + const svgText = reader.result as string + const nodes = parseSvgToNodes(svgText) + if (nodes.length === 0) return + const { viewport, fabricCanvas } = useCanvasStore.getState() - // Place image at center of current viewport const canvasEl = fabricCanvas?.getElement() const canvasW = canvasEl?.clientWidth ?? 800 const canvasH = canvasEl?.clientHeight ?? 600 const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom - // Scale down large images to fit reasonably on canvas - let w = img.naturalWidth - let h = img.naturalHeight - const maxDim = 400 - if (w > maxDim || h > maxDim) { - const scale = maxDim / Math.max(w, h) - w = Math.round(w * scale) - h = Math.round(h * scale) + for (const node of nodes) { + const w = ('width' in node ? (typeof node.width === 'number' ? node.width : 100) : 100) + const h = ('height' in node ? (typeof node.height === 'number' ? node.height : 100) : 100) + node.x = centerX - w / 2 + node.y = centerY - h / 2 + node.name = file.name.replace(/\.[^.]+$/, '') + useDocumentStore.getState().addNode(null, node) } - - useDocumentStore.getState().addNode(null, { - id: generateId(), - type: 'image', - name: file.name.replace(/\.[^.]+$/, ''), - src: dataUrl, - x: centerX - w / 2, - y: centerY - h / 2, - width: w, - height: h, - }) } - img.src = dataUrl + reader.readAsText(file) + } else { + // Raster image → ImageNode with data URL + const reader = new FileReader() + reader.onload = () => { + const dataUrl = reader.result as string + const img = new Image() + img.onload = () => { + const { viewport, fabricCanvas } = useCanvasStore.getState() + const canvasEl = fabricCanvas?.getElement() + const canvasW = canvasEl?.clientWidth ?? 800 + const canvasH = canvasEl?.clientHeight ?? 600 + const centerX = (-viewport.panX + canvasW / 2) / viewport.zoom + const centerY = (-viewport.panY + canvasH / 2) / viewport.zoom + + let w = img.naturalWidth + let h = img.naturalHeight + const maxDim = 400 + if (w > maxDim || h > maxDim) { + const scale = maxDim / Math.max(w, h) + w = Math.round(w * scale) + h = Math.round(h * scale) + } + + useDocumentStore.getState().addNode(null, { + id: generateId(), + type: 'image', + name: file.name.replace(/\.[^.]+$/, ''), + src: dataUrl, + x: centerX - w / 2, + y: centerY - h / 2, + width: w, + height: h, + }) + } + img.src = dataUrl + } + reader.readAsDataURL(file) } - reader.readAsDataURL(file) }, []) return ( diff --git a/src/components/panels/ai-chat-panel.tsx b/src/components/panels/ai-chat-panel.tsx index c28067da9..ea5833fd1 100644 --- a/src/components/panels/ai-chat-panel.tsx +++ b/src/components/panels/ai-chat-panel.tsx @@ -152,6 +152,8 @@ function useChatHandlers() { if (chunk.type === 'text') { accumulated += chunk.content updateLastMessage(accumulated) + } else if (chunk.type === 'thinking') { + // Model is in extended thinking phase — SSE heartbeat, no display update needed } else if (chunk.type === 'error') { accumulated += `\n\n**Error:** ${chunk.content}` updateLastMessage(accumulated) diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index f898b133d..6aa9efa4c 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -1,5 +1,6 @@ import { HeadContent, + Outlet, Scripts, createRootRoute, } from '@tanstack/react-router' @@ -27,9 +28,19 @@ export const Route = createRootRoute({ }, ], }), + component: RootComponent, + notFoundComponent: () => ( +
+

Page not found

+
+ ), shellComponent: RootDocument, }) +function RootComponent() { + return +} + function RootDocument({ children }: { children: React.ReactNode }) { return ( diff --git a/src/services/ai/ai-prompts.ts b/src/services/ai/ai-prompts.ts index 5ae8b9ffd..6a3251fa5 100644 --- a/src/services/ai/ai-prompts.ts +++ b/src/services/ai/ai-prompts.ts @@ -4,6 +4,8 @@ PenNode types (the ONLY format you output for designs): - rectangle: Props: width, height, cornerRadius, fill, stroke, effects - ellipse: Props: width, height, fill, stroke, effects - text: Props: content (string), fontFamily, fontSize, fontWeight, fill, width, height, textAlign +- path: SVG icon/shape. Props: d (SVG path string), width, height, fill, stroke, effects +- image: Raster image. Props: src (URL string), width, height, cornerRadius, effects All nodes share: id (string), type, name, x, y, rotation, opacity @@ -23,11 +25,16 @@ RULES: const DESIGN_EXAMPLES = ` EXAMPLES: -Button: -{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 160, "height": 44, "cornerRadius": 8, "layout": "horizontal", "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-text", "type": "text", "name": "Label", "content": "Click Me", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] } +Button with icon: +{ "id": "btn-1", "type": "frame", "name": "Button", "x": 100, "y": 100, "width": 180, "height": 44, "cornerRadius": 8, "layout": "horizontal", "gap": 8, "justifyContent": "center", "alignItems": "center", "fill": [{ "type": "solid", "color": "#3B82F6" }], "children": [{ "id": "btn-icon", "type": "path", "name": "ArrowIcon", "d": "M5 12h14M12 5l7 7-7 7", "width": 20, "height": 20, "stroke": { "thickness": 2, "fill": [{ "type": "solid", "color": "#FFFFFF" }] } }, { "id": "btn-text", "type": "text", "name": "Label", "content": "Continue", "fontSize": 16, "fontWeight": 600, "width": 80, "height": 22, "fill": [{ "type": "solid", "color": "#FFFFFF" }] }] } -Card: -{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 200, "cornerRadius": 12, "layout": "vertical", "padding": 20, "gap": 12, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] } +Card with image: +{ "id": "card-1", "type": "frame", "name": "Card", "x": 50, "y": 50, "width": 320, "height": 340, "cornerRadius": 12, "layout": "vertical", "gap": 0, "fill": [{ "type": "solid", "color": "#FFFFFF" }], "effects": [{ "type": "shadow", "offsetX": 0, "offsetY": 4, "blur": 12, "spread": 0, "color": "rgba(0,0,0,0.1)" }], "children": [{ "id": "card-img", "type": "image", "name": "Cover", "src": "https://picsum.photos/320/180", "width": 320, "height": 180 }, { "id": "card-body", "type": "frame", "name": "Body", "width": 320, "height": 140, "layout": "vertical", "padding": 20, "gap": 8, "children": [{ "id": "card-title", "type": "text", "name": "Title", "content": "Card Title", "fontSize": 20, "fontWeight": 700, "width": 280, "height": 28, "fill": [{ "type": "solid", "color": "#111827" }] }, { "id": "card-desc", "type": "text", "name": "Description", "content": "Some description text here", "fontSize": 14, "width": 280, "height": 20, "fill": [{ "type": "solid", "color": "#6B7280" }] }] }] } + +ICONS & IMAGES: +- Icons: Use "path" nodes with Lucide-style SVG d attribute (24x24 viewBox). Use stroke for line icons, fill for solid icons. Size 16-24px. +- Images: Use "image" nodes. src = "https://picsum.photos/{width}/{height}" for placeholders. Set explicit width/height. +- You know many Lucide icon SVG paths — use them freely. Always give icon nodes descriptive names. ` export const CHAT_SYSTEM_PROMPT = `You are a design assistant for OpenPencil, a vector design tool that renders PenNode JSON on a canvas. @@ -58,7 +65,10 @@ DESIGN GUIDELINES: - Text: titles 22-28px bold, body 14-16px, captions 12px - Buttons: height 44-48px, cornerRadius 8-12 - Inputs: height 44px, light bg, subtle border -- Consistent color palette` +- Consistent color palette +- Use path nodes for icons (SVG d path data, Lucide-style 24x24 viewBox). Size icons 16-24px in UI elements +- Use image nodes for photos/illustrations with picsum.photos placeholder URLs +- Buttons, nav items, and list items should include icons when appropriate for better UX` export const DESIGN_GENERATOR_PROMPT = `You are a PenNode JSON generation engine. Your ONLY job is to convert design descriptions into PenNode JSON. @@ -88,7 +98,13 @@ SIZING: - Use unique descriptive IDs - All colors as fill arrays: [{ "type": "solid", "color": "#hex" }] -Design like a professional: visual hierarchy, contrast, whitespace, consistent palette.` +ICONS & IMAGES: +- Use "path" nodes for icons: provide SVG d attribute, set width/height (16-24px for UI icons), use stroke for line icons or fill for solid icons +- Use "image" nodes for photos/illustrations: set src to "https://picsum.photos/{width}/{height}" as placeholder, set explicit width/height +- Include icons in buttons, nav items, list items, cards for professional polish +- Reference the icon patterns in the examples section for common icons + +Design like a professional: visual hierarchy, contrast, whitespace, consistent palette, purposeful iconography.` export const CODE_GENERATOR_PROMPT = `You are a code generation engine for OpenPencil. Convert PenNode design descriptions into clean, production-ready code. diff --git a/src/services/ai/ai-types.ts b/src/services/ai/ai-types.ts index 0e6fe291e..d591efead 100644 --- a/src/services/ai/ai-types.ts +++ b/src/services/ai/ai-types.ts @@ -22,6 +22,6 @@ export interface AICodeRequest { } export interface AIStreamChunk { - type: 'text' | 'done' | 'error' + type: 'text' | 'thinking' | 'done' | 'error' content: string } diff --git a/src/utils/svg-parser.ts b/src/utils/svg-parser.ts new file mode 100644 index 000000000..2e060569e --- /dev/null +++ b/src/utils/svg-parser.ts @@ -0,0 +1,378 @@ +import type { PenNode } from '@/types/pen' +import type { PenFill, PenStroke } from '@/types/styles' +import { generateId } from '@/stores/document-store' + +/** Inherited style context passed from parent SVG/g elements */ +interface StyleCtx { + fill: string | null + stroke: string | null + strokeWidth: number +} + +/** + * Parse an SVG string into editable PenNode array. + * Handles fill/stroke inheritance, path coordinate scaling, and viewBox transforms. + */ +export function parseSvgToNodes( + svgText: string, + maxDim = 400, +): PenNode[] { + const parser = new DOMParser() + const doc = parser.parseFromString(svgText, 'image/svg+xml') + const svg = doc.querySelector('svg') + if (!svg) return [] + + // Resolve source dimensions from viewBox or width/height + const vb = svg.getAttribute('viewBox')?.split(/[\s,]+/).map(Number) + const svgW = parseFloat(svg.getAttribute('width') ?? '') || vb?.[2] || 100 + const svgH = parseFloat(svg.getAttribute('height') ?? '') || vb?.[3] || 100 + + // Compute scale to fit within maxDim + const scale = + svgW > maxDim || svgH > maxDim ? maxDim / Math.max(svgW, svgH) : 1 + + // Build inherited style context from attributes + const rootCtx: StyleCtx = { + fill: svg.getAttribute('fill'), + stroke: svg.getAttribute('stroke'), + strokeWidth: parseFloat(svg.getAttribute('stroke-width') ?? '') || 1, + } + + const nodes = parseChildren(svg, scale, rootCtx) + + if (nodes.length === 0) return [] + if (nodes.length === 1) return nodes + + // Wrap multiple elements in a frame + return [ + { + id: generateId(), + type: 'frame', + name: 'SVG', + width: Math.round(svgW * scale), + height: Math.round(svgH * scale), + layout: 'none' as const, + children: nodes, + }, + ] +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +const SKIP_TAGS = new Set([ + 'defs', 'style', 'title', 'desc', 'metadata', + 'clippath', 'mask', 'filter', 'lineargradient', 'radialgradient', + 'symbol', 'marker', 'pattern', +]) + +function parseChildren( + parent: Element, + scale: number, + ctx: StyleCtx, +): PenNode[] { + const nodes: PenNode[] = [] + for (const el of parent.children) { + const node = parseElement(el, scale, ctx) + if (node) nodes.push(node) + } + return nodes +} + +function parseElement( + el: Element, + scale: number, + parentCtx: StyleCtx, +): PenNode | null { + const tag = el.tagName.toLowerCase() + if (SKIP_TAGS.has(tag)) return null + + // Merge this element's style context with parent + const ctx = mergeStyleCtx(parentCtx, el) + + // Handle by recursing with inherited styles + if (tag === 'g') { + const children = parseChildren(el, scale, ctx) + if (children.length === 0) return null + if (children.length === 1) return children[0] + return { + id: generateId(), + type: 'frame', + name: el.getAttribute('id') ?? 'Group', + layout: 'none' as const, + children, + } + } + + // Resolve fill & stroke from element + inherited context + const fill = resolveFill(el, ctx) + const stroke = resolveStroke(el, ctx, scale) + const opacity = parseFloat(getAttr(el, 'opacity') ?? '1') + + const base = { + id: generateId(), + opacity: opacity !== 1 ? opacity : undefined, + } + + switch (tag) { + case 'path': { + const d = el.getAttribute('d') + if (!d) return null + const scaledD = scaleSvgPath(d, scale) + const bbox = getPathBBox(scaledD) + return { + ...base, + type: 'path' as const, + name: el.getAttribute('id') ?? 'Path', + d: scaledD, + x: bbox.x, + y: bbox.y, + width: Math.round(bbox.w), + height: Math.round(bbox.h), + fill, + stroke, + } + } + + case 'rect': { + const x = num(el, 'x') * scale + const y = num(el, 'y') * scale + const w = num(el, 'width') * scale + const h = num(el, 'height') * scale + const rx = num(el, 'rx') * scale + return { + ...base, + type: 'rectangle' as const, + name: el.getAttribute('id') ?? 'Rectangle', + x, y, + width: Math.round(w), + height: Math.round(h), + cornerRadius: rx || undefined, + fill, + stroke, + } + } + + case 'circle': { + const cx = num(el, 'cx') * scale + const cy = num(el, 'cy') * scale + const r = num(el, 'r') * scale + return { + ...base, + type: 'ellipse' as const, + name: el.getAttribute('id') ?? 'Circle', + x: cx - r, y: cy - r, + width: Math.round(r * 2), + height: Math.round(r * 2), + fill, + stroke, + } + } + + case 'ellipse': { + const cx = num(el, 'cx') * scale + const cy = num(el, 'cy') * scale + const rx = num(el, 'rx') * scale + const ry = num(el, 'ry') * scale + return { + ...base, + type: 'ellipse' as const, + name: el.getAttribute('id') ?? 'Ellipse', + x: cx - rx, y: cy - ry, + width: Math.round(rx * 2), + height: Math.round(ry * 2), + fill, + stroke, + } + } + + case 'line': { + return { + ...base, + type: 'line' as const, + name: el.getAttribute('id') ?? 'Line', + x: num(el, 'x1') * scale, + y: num(el, 'y1') * scale, + x2: num(el, 'x2') * scale, + y2: num(el, 'y2') * scale, + stroke: stroke ?? { thickness: 1, fill: [{ type: 'solid', color: '#000000' }] }, + } + } + + case 'polygon': + case 'polyline': { + const pts = el.getAttribute('points') + if (!pts) return null + const scaledD = scaleSvgPath(pointsToD(pts, tag === 'polygon'), scale) + const bbox = getPathBBox(scaledD) + return { + ...base, + type: 'path' as const, + name: el.getAttribute('id') ?? (tag === 'polygon' ? 'Polygon' : 'Polyline'), + d: scaledD, + x: bbox.x, + y: bbox.y, + width: Math.round(bbox.w), + height: Math.round(bbox.h), + fill: tag === 'polygon' ? fill : noFill(), + stroke, + } + } + + default: + return null + } +} + +// --------------------------------------------------------------------------- +// Style resolution with inheritance +// --------------------------------------------------------------------------- + +/** Read an attribute from the element, checking inline `style` first */ +function getAttr(el: Element, name: string): string | null { + // Check inline style first (higher priority) + const style = el.getAttribute('style') + if (style) { + const m = style.match(new RegExp(`${name}\\s*:\\s*([^;]+)`)) + if (m) return m[1].trim() + } + return el.getAttribute(name) +} + +/** Build style context by merging parent context with this element's attributes */ +function mergeStyleCtx(parent: StyleCtx, el: Element): StyleCtx { + return { + fill: getAttr(el, 'fill') ?? parent.fill, + stroke: getAttr(el, 'stroke') ?? parent.stroke, + strokeWidth: parseFloat(getAttr(el, 'stroke-width') ?? '') || parent.strokeWidth, + } +} + +/** Normalize a color string: resolve currentColor, inherit → black */ +function normalizeColor(raw: string): string { + if (raw === 'currentColor' || raw === 'inherit') return '#000000' + return raw +} + +/** Explicit "no fill" — transparent so canvas DEFAULT_FILL won't apply */ +function noFill(): PenFill[] { + return [{ type: 'solid', color: 'transparent' }] +} + +/** Resolve fill from element attribute + inherited context → PenFill[] */ +function resolveFill(el: Element, ctx: StyleCtx): PenFill[] | undefined { + const raw = getAttr(el, 'fill') ?? ctx.fill + + // Explicit "none" → transparent fill + if (raw === 'none') return noFill() + // url(#gradient) references — not supported, use black fallback + if (raw && raw.startsWith('url(')) return [{ type: 'solid', color: '#000000' }] + // Has a color + if (raw) return [{ type: 'solid', color: normalizeColor(raw) }] + // SVG spec default: black fill + return [{ type: 'solid', color: '#000000' }] +} + +/** Resolve stroke from element attribute + inherited context → PenStroke */ +function resolveStroke(el: Element, ctx: StyleCtx, scale: number): PenStroke | undefined { + const raw = getAttr(el, 'stroke') ?? ctx.stroke + if (!raw || raw === 'none') return undefined + if (raw.startsWith('url(')) return undefined + + const width = (parseFloat(getAttr(el, 'stroke-width') ?? '') || ctx.strokeWidth) * scale + return { + thickness: width, + fill: [{ type: 'solid', color: normalizeColor(raw) }], + } +} + +// --------------------------------------------------------------------------- +// SVG path data scaling +// --------------------------------------------------------------------------- + +/** + * Scale all coordinates in an SVG path `d` string by a factor. + * Handles M/L/C/S/Q/T/H/V/A commands (both absolute and relative). + * For arc (A) commands, only rx/ry/x/y are scaled — flags and rotation are preserved. + */ +function scaleSvgPath(d: string, scale: number): string { + if (scale === 1) return d + + // Tokenize into commands and numbers + const tokens = d.match(/[a-zA-Z]|[-+]?(?:\d+\.?\d*|\.\d+)(?:[eE][-+]?\d+)?/g) + if (!tokens) return d + + let result = '' + let cmd = '' + let paramIdx = 0 + + for (const tok of tokens) { + if (/[a-zA-Z]/.test(tok)) { + cmd = tok + paramIdx = 0 + result += tok + continue + } + + const n = parseFloat(tok) + const upper = cmd.toUpperCase() + + if (upper === 'A') { + // Arc: rx ry x-rotation large-arc-flag sweep-flag x y (7 params per arc) + const pos = paramIdx % 7 + // Scale rx(0), ry(1), x(5), y(6); keep rotation(2), flags(3,4) unchanged + const shouldScale = pos === 0 || pos === 1 || pos === 5 || pos === 6 + result += ' ' + (shouldScale ? n * scale : n) + } else { + // All other commands: every param is a coordinate → scale it + result += ' ' + n * scale + } + paramIdx++ + } + + return result.trim() +} + +// --------------------------------------------------------------------------- +// Path bounding box (uses browser SVG engine for accuracy) +// --------------------------------------------------------------------------- + +/** Compute the bounding box of an SVG path d string via the browser's SVG DOM */ +function getPathBBox(d: string): { x: number; y: number; w: number; h: number } { + try { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg') + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path') + path.setAttribute('d', d) + svg.appendChild(path) + svg.style.position = 'absolute' + svg.style.width = '0' + svg.style.height = '0' + svg.style.overflow = 'hidden' + document.body.appendChild(svg) + const bbox = path.getBBox() + document.body.removeChild(svg) + return { x: bbox.x, y: bbox.y, w: bbox.width, h: bbox.height } + } catch { + return { x: 0, y: 0, w: 100, h: 100 } + } +} + +// --------------------------------------------------------------------------- +// Utility +// --------------------------------------------------------------------------- + +function num(el: Element, attr: string): number { + return parseFloat(getAttr(el, attr) ?? '0') || 0 +} + +function pointsToD(points: string, close: boolean): string { + const nums = points.trim().split(/[\s,]+/).map(Number) + if (nums.length < 2) return '' + let d = `M${nums[0]} ${nums[1]}` + for (let i = 2; i < nums.length - 1; i += 2) { + d += `L${nums[i]} ${nums[i + 1]}` + } + if (close) d += 'Z' + return d +}