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:
parent
78e2c7f4c3
commit
6f73b28f3d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'"/>`)
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue