fix(core): preserve inline SVG primitives (#485)

* fix(core): preserve inline SVG primitives

- Convert parsed circle, ellipse, rect, line, polyline, and polygon children through the shared SVG path pipeline
- Preserve nested presentation attributes, transforms, and JSX-style SVG attribute names
- Cover mixed and primitive-only inline SVG renders

* chore: format merged mobile navigation

* fix(core): disambiguate xmldom document types

- Alias the xmldom Document type so clean CI does not resolve it as the browser DOM type
- Guard the generated SVG document root before collecting paths

* fix(core): preserve inline SVG root styles

- Carry root presentation properties into parsed SVG child conversion
- Apply nested SVG transforms before creating vector networks
- Cover inherited root fills and transformed primitive geometry

* fix(core): scale transformed SVG strokes
This commit is contained in:
Danila Poyarkov 2026-08-12 16:02:45 +03:00 committed by GitHub
parent 78e2c7f4c3
commit 6f73b28f3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 194 additions and 36 deletions

View file

@ -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

View file

@ -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 <path>/<circle>/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<typeof p> => p !== null)
pathInfos = extractPathsFromElements(tree.children.filter(isTreeNode), props)
}
if (pathInfos.length === 0) {
throw new Error(
'<svg> requires SVG markup as children, a body prop, or <path d="..."> children'
)
throw new Error('<svg> requires SVG markup, a body prop, or supported SVG shape children')
}
const vb = parseViewBox(props.viewBox as string | undefined)

View file

@ -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<Record<string, unknown>>
children: readonly (SVGElementInput | string)[]
}
const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'
const JSX_ATTRIBUTE_NAMES: Readonly<Record<string, string>> = {
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<string, Element>()
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<Record<string, unknown>> = {}
): 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 <svg>). */
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
}

View file

@ -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(
'<line x1="0" y1="0" x2="4" y2="0" stroke="black" stroke-width="3" transform="scale(2)" />'
),
1,
1
)
const [nonUniform] = scalePathInfos(
extractPaths(
'<line x1="0" y1="0" x2="4" y2="0" stroke="black" stroke-width="3" transform="scale(4 2)" />'
),
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(`<path d="M0 0L10 10" data-note="fill='red' stroke='blue'"/>`)

View file

@ -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,
`<svg viewBox="0 0 100 100" size={100}>
<path d="M0 0 L10 0" fill="#111" />
<ellipse cx="50" cy="50" rx="20" ry="10" fill="#F00" />
<circle cx="50" cy="50" r="10" fill="#0F0" />
<rect x="1" y="1" width="9" height="9" rx="2" fill="#00F" />
<line x1="10" y1="20" x2="30" y2="40" stroke="#123" />
<polyline points="60,10 70,20 80,10" fill="none" stroke="#456" />
<polygon points="60,60 80,60 70,80" fill="#789" />
</svg>`
)
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,
`<svg viewBox="0 0 100 100" size={100} fill="#FF0000">
<g transform="translate(30 20) scale(2)"><rect x="1" y="2" width="4" height="5" /></g>
</svg>`
)
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,
`<svg viewBox="0 0 24 24" size={24}><circle cx="12" cy="12" r="10" fill="#0F0" /></svg>`
)
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(