diff --git a/CHANGELOG.md b/CHANGELOG.md index b642629e2..7d805cd38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Keep imported Figma instances linked to their remapped source components so later component edits update existing instances. (#385) - Restore native copy, cut, and paste shortcuts in desktop text inputs while preserving design clipboard handling on the canvas. - Complete translated app, accessibility, font, color, collaboration, import, connection-test, and browser fallback text across all supported locales, and keep the document language synchronized with the selected locale. +- Preserve circles, ellipses, rectangles, lines, polylines, and polygons supplied as JSX children of inline SVG elements. (#452) ## 0.14.0 - 2026-08-10 diff --git a/packages/core/src/design-jsx/renderer.ts b/packages/core/src/design-jsx/renderer.ts index c850c47fe..05da85019 100644 --- a/packages/core/src/design-jsx/renderer.ts +++ b/packages/core/src/design-jsx/renderer.ts @@ -10,7 +10,7 @@ import { parseColor } from '#core/color' import type { RenderOptions } from '#core/design-jsx/types' import { fetchIcons } from '#core/icons' import { createIconFromPaths } from '#core/icons/render' -import { extractPaths, scalePathInfos } from '#core/icons/svg' +import { extractPaths, extractPathsFromElements, scalePathInfos } from '#core/icons/svg' import type { IconData } from '#core/icons/types' import { computeAllLayouts } from '#core/layout' import { randomHex } from '#core/random' @@ -264,35 +264,15 @@ async function renderSVGNode( (typeof props.body === 'string' && props.body) || tree.children.filter((c): c is string => typeof c === 'string').join('') - // Children may arrive as parsed //etc. elements (mini-react - // lowercases tags) rather than raw markup. Rebuild path info from either - // source: raw SVG markup, or element children carrying a `d` attribute. + // Children may arrive as parsed SVG elements (mini-react lowercases tags) + // rather than raw markup. Route both representations through the shared SVG + // shape conversion so path and primitive children have identical behavior. let pathInfos = body.trim() ? extractPaths(body) : [] if (pathInfos.length === 0) { - pathInfos = tree.children - .filter(isTreeNode) - .map((child) => { - const d = (child.props.d ?? child.props.body) as string | undefined - if (!d) return null - return { - d, - fill: (child.props.fill as string | undefined) ?? 'currentColor', - stroke: (child.props.stroke as string | undefined) ?? null, - strokeWidth: Number(child.props['stroke-width'] ?? child.props.strokeWidth ?? 1), - strokeCap: (child.props['stroke-linecap'] as string | undefined) ?? 'butt', - strokeJoin: (child.props['stroke-linejoin'] as string | undefined) ?? 'miter', - fillRule: - (child.props['fill-rule'] as string | undefined) === 'evenodd' - ? ('EVENODD' as const) - : ('NONZERO' as const) - } - }) - .filter((p): p is NonNullable => p !== null) + pathInfos = extractPathsFromElements(tree.children.filter(isTreeNode), props) } if (pathInfos.length === 0) { - throw new Error( - ' requires SVG markup as children, a body prop, or children' - ) + throw new Error(' requires SVG markup, a body prop, or supported SVG shape children') } const vb = parseViewBox(props.viewBox as string | undefined) diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 01176b625..7ab25ad60 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -1,13 +1,35 @@ import { iconToSVG } from '@iconify/utils' -import type { Element, Node } from '@xmldom/xmldom' +import { + DOMImplementation, + type Document as XMLDocument, + type Element, + type Node +} from '@xmldom/xmldom' import svgpath from 'svgpath' import { parseSVGPath } from '@open-pencil/scene-graph/parse-path' +import type { Vector } from '@open-pencil/scene-graph/primitives' import { parseSVGFragment } from '#core/io/formats/svg/document' import type { IconData, IconifyIconEntry, IconPathInfo } from './types' +interface SVGElementInput { + type: string + props: Readonly> + children: readonly (SVGElementInput | string)[] +} + +const SVG_NAMESPACE = 'http://www.w3.org/2000/svg' +const JSX_ATTRIBUTE_NAMES: Readonly> = { + className: 'class', + fillRule: 'fill-rule', + strokeLinecap: 'stroke-linecap', + strokeLinejoin: 'stroke-linejoin', + strokeWidth: 'stroke-width', + xlinkHref: 'xlink:href' +} + interface PresentationAttributes { fill: string stroke: string @@ -235,9 +257,24 @@ function collectPaths( } } -export function extractPaths(svgBody: string): IconPathInfo[] { - const root = parseSVGFragment(svgBody)?.documentElement - if (!root) return [] +function appendSVGElement(svgDocument: XMLDocument, parent: Element, input: SVGElementInput): void { + if (!/^[A-Za-z][\w:.-]*$/.test(input.type)) return + const element = svgDocument.createElementNS(SVG_NAMESPACE, input.type) + for (const [propName, value] of Object.entries(input.props)) { + if (typeof value !== 'string' && typeof value !== 'number') continue + const attributeName = JSX_ATTRIBUTE_NAMES[propName] ?? propName + element.setAttribute(attributeName, String(value)) + } + if (input.type === 'path' && !element.hasAttribute('d') && typeof input.props.body === 'string') { + element.setAttribute('d', input.props.body) + } + for (const child of input.children) { + if (typeof child !== 'string') appendSVGElement(svgDocument, element, child) + } + parent.appendChild(element) +} + +function collectDocumentPaths(root: Element): IconPathInfo[] { const elementsById = new Map() for (const element of Array.from(root.getElementsByTagName('*'))) { const id = element.getAttribute('id') @@ -248,6 +285,22 @@ export function extractPaths(svgBody: string): IconPathInfo[] { return result } +export function extractPathsFromElements( + elements: readonly SVGElementInput[], + rootProps: Readonly> = {} +): IconPathInfo[] { + const svgDocument = new DOMImplementation().createDocument(SVG_NAMESPACE, 'svg') + const root = svgDocument.documentElement + if (!root) return [] + appendSVGElement(svgDocument, root, { type: 'svg', props: rootProps, children: elements }) + return collectDocumentPaths(root) +} + +export function extractPaths(svgBody: string): IconPathInfo[] { + const root = parseSVGFragment(svgBody)?.documentElement + return root ? collectDocumentPaths(root) : [] +} + export function buildIconData( iconEntry: IconifyIconEntry, prefix: string, @@ -276,6 +329,25 @@ export function buildIconData( } } +function transformStrokeScale(transform: string | null | undefined): number { + if (!transform || transform === 'none') return 1 + + const points: Vector[] = [] + svgpath('M0 0 L1 0 M0 0 L0 1') + .transform(transform) + .abs() + .iterate((segment) => { + if (segment[0] === 'M' || segment[0] === 'L') { + points.push({ x: segment[1], y: segment[2] }) + } + }) + if (points.length < 4) return 1 + + const xScale = Math.hypot(points[1].x - points[0].x, points[1].y - points[0].y) + const yScale = Math.hypot(points[3].x - points[2].x, points[3].y - points[2].y) + return Math.min(xScale, yScale) +} + /** Scale extracted SVG path info into IconData paths (shared by buildIconData and design-jsx ). */ export function scalePathInfos( pathInfos: IconPathInfo[], @@ -283,15 +355,18 @@ export function scalePathInfos( scaleY: number ): IconData['paths'] { return pathInfos.map((path) => { - const scaledD = - scaleX === 1 && scaleY === 1 - ? path.d - : svgpath(path.d).scale(scaleX, scaleY).round(2).toString() + let transformedPath = svgpath(path.d) + if (path.transform && path.transform !== 'none') { + transformedPath = transformedPath.transform(path.transform) + } + if (scaleX !== 1 || scaleY !== 1) transformedPath = transformedPath.scale(scaleX, scaleY) + const scaledD = transformedPath.round(2).toString() return { vectorNetwork: parseSVGPath(scaledD, path.fillRule), fill: normalizeSVGPaint(path.fill), stroke: normalizeSVGPaint(path.stroke), - strokeWidth: path.strokeWidth * Math.min(scaleX, scaleY), + strokeWidth: + path.strokeWidth * transformStrokeScale(path.transform) * Math.min(scaleX, scaleY), strokeCap: path.strokeCap, strokeJoin: path.strokeJoin } diff --git a/tests/engine/icons/svg.test.ts b/tests/engine/icons/svg.test.ts index 9a431d3ee..761e64f37 100644 --- a/tests/engine/icons/svg.test.ts +++ b/tests/engine/icons/svg.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' -import { extractPaths } from '#core/icons/svg' +import { extractPaths, extractPathsFromElements, scalePathInfos } from '#core/icons/svg' import { parseSVGSize, parseSVGViewBox } from '#core/io/formats/svg/metadata' describe('SVG XML parsing', () => { @@ -32,6 +32,55 @@ describe('SVG XML parsing', () => { }) }) + test('converts parsed element primitives through the shared SVG pipeline', () => { + const paths = extractPathsFromElements( + [ + { + type: 'g', + props: { transform: 'translate(4 5)' }, + children: [ + { type: 'circle', props: { cx: 10, cy: 10, r: 5 }, children: [] }, + { + type: 'rect', + props: { x: 20, y: 20, width: 10, height: 8, rx: 2, strokeWidth: 3 }, + children: [] + } + ] + } + ], + { fill: '#ff0000' } + ) + + expect(paths).toHaveLength(2) + expect(paths[0]).toMatchObject({ fill: '#ff0000', transform: 'translate(4 5)' }) + expect(paths[1]).toMatchObject({ + fill: '#ff0000', + strokeWidth: 3, + transform: 'translate(4 5)' + }) + expect(paths.every((path) => path.d.length > 0)).toBe(true) + }) + + test('scales transformed strokes with the conservative non-uniform axis', () => { + const [uniform] = scalePathInfos( + extractPaths( + '' + ), + 1, + 1 + ) + const [nonUniform] = scalePathInfos( + extractPaths( + '' + ), + 1, + 1 + ) + + expect(uniform?.strokeWidth).toBe(6) + expect(nonUniform?.strokeWidth).toBe(6) + }) + test('does not interpret attribute-like path text as markup', () => { const paths = extractPaths(``) diff --git a/tests/engine/render/jsx/gaps.test.ts b/tests/engine/render/jsx/gaps.test.ts index cb254045d..c70659924 100644 --- a/tests/engine/render/jsx/gaps.test.ts +++ b/tests/engine/render/jsx/gaps.test.ts @@ -27,6 +27,59 @@ describe('jsx gaps', () => { expect(vector?.vectorNetwork).toBeTruthy() }) + it('renders SVG element children for every supported primitive', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + ` + + + + + + + + ` + ) + + const vectors = [...g.nodes.values()].filter((node) => node.type === 'VECTOR') + expect(vectors).toHaveLength(7) + expect(vectors.filter((node) => node.fills.length > 0)).toHaveLength(6) + expect(vectors.filter((node) => node.strokes.length > 0)).toHaveLength(2) + expect(vectors.every((node) => (node.vectorNetwork?.vertices.length ?? 0) > 0)).toBe(true) + }) + + it('preserves root presentation and applies nested transforms', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + ` + + ` + ) + + const vector = [...g.nodes.values()].find((node) => node.type === 'VECTOR') + expect(vector?.fills[0]?.color).toMatchObject({ r: 1, g: 0, b: 0 }) + expect(vector?.vectorNetwork?.vertices.map(({ x, y }) => ({ x, y }))).toEqual([ + { x: 32, y: 24 }, + { x: 40, y: 24 }, + { x: 40, y: 34 }, + { x: 32, y: 34 } + ]) + }) + + it('renders an SVG containing only a circle', async () => { + const g = makeSceneGraph() + await renderJSX( + g, + `` + ) + + const vectors = [...g.nodes.values()].filter((node) => node.type === 'VECTOR') + expect(vectors).toHaveLength(1) + expect(vectors[0].fills).toHaveLength(1) + }) + it('renders open and closed SVG paths as separate stroked vectors', async () => { const g = makeSceneGraph() await renderJSX(