Merge pull request #513 from open-pencil/fix-svg-clip-path-import

fix(io): preserve SVG clip paths
This commit is contained in:
Danila Poyarkov 2026-08-14 14:21:43 +03:00 committed by GitHub
commit dd8bb7aa5c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 267 additions and 13 deletions

View file

@ -33,6 +33,7 @@
- Remove the permanent CORS configuration action from cloud-storage settings and report connection results through standard toasts with clear browser-specific guidance.
- 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 effective nested instance text overrides when importing complex Figma component hierarchies. (#102)
- Preserve SVG clip paths, including clip shapes referenced through `<use>`, when importing editable vectors.
- 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

@ -12,7 +12,7 @@ import type { Vector } from '@open-pencil/scene-graph/primitives'
import { parseSVGFragment } from '#core/io/formats/svg/document'
import type { IconData, IconifyIconEntry, IconPathInfo } from './types'
import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathRegion } from './types'
interface SVGElementInput {
type: string
@ -185,6 +185,7 @@ function appendShapePath(
element: Element,
presentation: PresentationAttributes,
transform: string | null,
clipPaths: SVGClipPathRegion[],
result: IconPathInfo[]
): void {
if (!SHAPE_NAMES.has(tagName)) return
@ -199,7 +200,8 @@ function appendShapePath(
strokeCap: presentation.strokeCap,
strokeJoin: presentation.strokeJoin,
fillRule: presentation.fillRule === 'evenodd' ? 'EVENODD' : 'NONZERO',
transform
transform,
clipPaths: clipPaths.length > 0 ? clipPaths : undefined
})
}
@ -209,7 +211,8 @@ function collectUsePaths(
transform: string | null,
result: IconPathInfo[],
elementsById: ReadonlyMap<string, Element>,
useStack: ReadonlySet<Element>
useStack: ReadonlySet<Element>,
clipPaths: SVGClipPathRegion[]
): boolean {
const tagName = element.localName || element.tagName
if (tagName !== 'use') return false
@ -227,12 +230,42 @@ function collectUsePaths(
result,
elementsById,
new Set([...useStack, target]),
true
true,
clipPaths
)
}
return true
}
function collectClipPath(
value: string | null,
parentTransform: string | null,
elementsById: ReadonlyMap<string, Element>
): SVGClipPathRegion | null {
const match = value?.trim().match(/^url\(\s*['"]?#([^'")\s]+)['"]?\s*\)$/)
const target = match ? elementsById.get(match[1]) : null
if (!target || (target.localName || target.tagName) !== 'clipPath') return null
const units =
target.getAttribute('clipPathUnits') === 'objectBoundingBox'
? 'objectBoundingBox'
: 'userSpaceOnUse'
const paths: IconPathInfo[] = []
collectPaths(
target,
{ ...DEFAULT_PRESENTATION, fill: '#000000' },
units === 'objectBoundingBox' ? null : parentTransform,
paths,
elementsById,
new Set([target]),
true
)
return {
paths: paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })),
units
}
}
function collectPaths(
element: Element,
inherited: PresentationAttributes,
@ -240,19 +273,32 @@ function collectPaths(
result: IconPathInfo[],
elementsById: ReadonlyMap<string, Element>,
useStack: ReadonlySet<Element> = new Set(),
referenced = false
referenced = false,
inheritedClipPaths: SVGClipPathRegion[] = []
): void {
const tagName = element.localName || element.tagName
if (NON_RENDERED_CONTAINERS.has(tagName) && !referenced) return
const presentation = presentationFor(element, inherited)
const transform = combinedTransform(parentTransform, element)
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return
appendShapePath(tagName, element, presentation, transform, result)
const ownClipPath = collectClipPath(element.getAttribute('clip-path'), transform, elementsById)
const clipPaths = ownClipPath ? [...inheritedClipPaths, ownClipPath] : inheritedClipPaths
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack, clipPaths))
return
appendShapePath(tagName, element, presentation, transform, clipPaths, result)
for (const child of Array.from(element.childNodes)) {
if (isElement(child)) {
collectPaths(child, presentation, transform, result, elementsById, useStack, referenced)
collectPaths(
child,
presentation,
transform,
result,
elementsById,
useStack,
referenced,
clipPaths
)
}
}
}

View file

@ -45,6 +45,15 @@ export interface IconPathInfo {
strokeCap: string
strokeJoin: string
fillRule: WindingRule
/** Nested SVG clip regions, ordered from outermost to innermost. */
clipPaths?: SVGClipPathRegion[]
/** Raw transform attribute from the source SVG element. */
transform?: string | null
}
export type SVGClipPathInfo = Pick<IconPathInfo, 'd' | 'fillRule' | 'transform'>
export interface SVGClipPathRegion {
paths: SVGClipPathInfo[]
units: 'userSpaceOnUse' | 'objectBoundingBox'
}

View file

@ -27,7 +27,8 @@ interface NormalizedVectorGeometry {
bounds: Rect
}
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'>
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'> &
Partial<Pick<SceneNode, 'isMask' | 'maskType'>>
function shouldTightenToContent(
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
@ -206,6 +207,64 @@ export function createVectorFrameChildren(
}
}
function createClipFrame(
graph: SceneGraph,
frameId: string,
placement: VectorFramePlacement,
index: number
): SceneNode {
return graph.createNode('FRAME', frameId, {
name: `clip ${index + 1}`,
x: 0,
y: 0,
width: placement.width,
height: placement.height,
fills: []
})
}
function createClipMaskChild(
graph: SceneGraph,
frameId: string,
clipNetwork: VectorNetwork,
placement: VectorFramePlacement,
index: number
): void {
const network = offsetVectorNetwork(clipNetwork, placement.offsetX, placement.offsetY)
const normalized = normalizeVectorToNodeBounds(network)
if (!normalized) return
createNormalizedVectorChild(graph, frameId, normalized, index, {
fillGeometry: [],
fills: [
{
type: 'SOLID',
color: { r: 1, g: 1, b: 1, a: 1 },
opacity: 1,
visible: true
}
],
strokes: [],
isMask: true,
maskType: 'VECTOR'
})
}
function createClipFrames(
graph: SceneGraph,
frameId: string,
clipNetworks: VectorNetwork[],
placement: VectorFramePlacement,
index: number
): string {
let targetFrameId = frameId
for (const clipNetwork of clipNetworks) {
const clipFrame = createClipFrame(graph, targetFrameId, placement, index)
createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index)
targetFrameId = clipFrame.id
}
return targetFrameId
}
function isFlattenableVectorPath(path: VectorizedPath): boolean {
return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0
}
@ -218,28 +277,42 @@ export function createFlattenedVectorFrameChildren(
placement: VectorFramePlacement
): void {
let run: { path: VectorizedPath; index: number }[] = []
let runClipNetworks: VectorNetwork[] | undefined
const flush = () => {
if (run.length === 0) return
const targetFrameId = runClipNetworks
? createClipFrames(graph, frameId, runClipNetworks, placement, run[0].index)
: frameId
if (run.length > 1) {
createFlattenedVectorChild(
graph,
frameId,
targetFrameId,
run.map(({ path }) => path),
placement,
run[0].index
)
} else if (run[0]) {
createVectorChild(graph, frameId, run[0].path, placement, run[0].index)
createVectorChild(graph, targetFrameId, run[0].path, placement, run[0].index)
}
run = []
runClipNetworks = undefined
}
for (const [index, path] of vectorized.paths.entries()) {
const clipNetworks = path.clipNetworks
if (isFlattenableVectorPath(path)) {
if (run.length > 0 && runClipNetworks !== clipNetworks) flush()
runClipNetworks = clipNetworks
run.push({ path, index })
continue
}
flush()
createVectorChild(graph, frameId, path, placement, index)
if (clipNetworks) {
const targetFrameId = createClipFrames(graph, frameId, clipNetworks, placement, index)
createVectorChild(graph, targetFrameId, path, placement, index)
} else {
createVectorChild(graph, frameId, path, placement, index)
}
}
flush()
}

View file

@ -5,7 +5,10 @@
* reflect the input pixel size. Scale path data from the SVG coordinate space
* (viewBox, else width/height) into the target node bounds before parsing.
*/
import svgpath from 'svgpath'
import type { Fill, Stroke, VectorNetwork, WindingRule } from '@open-pencil/scene-graph'
import { mergeVectorNetworks } from '@open-pencil/scene-graph'
import { computeBounds } from '@open-pencil/scene-graph/geometry'
import { parseSVGPath } from '@open-pencil/scene-graph/parse-path'
import type { Rect, Size } from '@open-pencil/scene-graph/primitives'
@ -59,6 +62,7 @@ export interface VectorizedPath {
vectorNetwork: VectorNetwork
fills: Fill[]
strokes: Stroke[]
clipNetworks?: VectorNetwork[]
}
export interface SVGVectorizeResult {
@ -89,12 +93,14 @@ export function svgToVectorPaths(
const strokeScale = Math.min(viewport.scaleX, viewport.scaleY)
const vectorized: VectorizedPath[] = []
const clipCache = new WeakMap<NonNullable<IconPathInfo['clipPaths']>, VectorNetwork[]>()
for (const path of paths) {
const fillRule: WindingRule = path.fillRule
const transform = path.transform ?? null
const pathData = applySVGTransformToPath(path.d, transform)
const scaledD = mapSVGPathToViewport(pathData, viewport)
const network = parseSVGPath(scaledD, fillRule)
const pathBounds = computeAccurateBounds(network)
const gradientFill =
gradients.size > 0
? resolveGradientFill(
@ -105,10 +111,36 @@ export function svgToVectorPaths(
computeAccurateBounds(network)
)
: null
let clipNetworks: VectorNetwork[] | undefined
if (path.clipPaths) {
const hasObjectBoundingBoxClip = path.clipPaths.some(
({ units }) => units === 'objectBoundingBox'
)
clipNetworks = hasObjectBoundingBoxClip ? undefined : clipCache.get(path.clipPaths)
if (!clipNetworks) {
clipNetworks = path.clipPaths.map((clipRegion) =>
mergeVectorNetworks(
clipRegion.paths.map((clipPath) => {
let clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null)
if (clipRegion.units === 'objectBoundingBox') {
clipData = svgpath(clipData)
.scale(pathBounds.width, pathBounds.height)
.translate(pathBounds.x, pathBounds.y)
.toString()
return parseSVGPath(clipData, clipPath.fillRule)
}
return parseSVGPath(mapSVGPathToViewport(clipData, viewport), clipPath.fillRule)
})
)
)
if (!hasObjectBoundingBoxClip) clipCache.set(path.clipPaths, clipNetworks)
}
}
vectorized.push({
vectorNetwork: network,
fills: gradientFill ? [gradientFill] : resolveFill(path, defaultColor),
strokes: resolveStrokes(path, defaultColor, strokeScale)
strokes: resolveStrokes(path, defaultColor, strokeScale),
clipNetworks
})
}

View file

@ -263,6 +263,99 @@ describe('import_svg', () => {
expect(path.fills[0].color.b).toBeCloseTo(1)
})
test('imports clip paths as masks for clipped paint runs', async () => {
const result = (await importSVG.execute(figma, {
svg: `<svg viewBox="0 0 100 100">
<defs><path id="mark" d="M10 10H90V90H10Z"/></defs>
<g clip-path="url(#clip)">
<defs><clipPath id="clip"><use href="#mark"/></clipPath></defs>
<rect width="50" height="100" fill="#ff0000"/>
<rect x="50" width="50" height="100" fill="#0000ff"/>
</g>
<circle cx="50" cy="50" r="10" fill="#ffffff"/>
</svg>`
})) as { id: string }
const children = graph.getChildren(result.id)
expect(children).toHaveLength(2)
const clippedGroup = expectDefined(children[0])
expect(clippedGroup.type).toBe('FRAME')
const clippedChildren = graph.getChildren(clippedGroup.id)
expect(clippedChildren).toHaveLength(2)
expect(clippedChildren[0].isMask).toBe(true)
expect(clippedChildren[0].maskType).toBe('VECTOR')
expect(expectDefined(clippedChildren[0].vectorNetwork).regions).toHaveLength(1)
expect(clippedChildren[1].fillGeometry).toHaveLength(2)
expect(children[1].isMask).toBe(false)
})
test('preserves inherited clips when expanding use elements', async () => {
const result = (await importSVG.execute(figma, {
svg: `<svg viewBox="0 0 100 100">
<defs>
<path id="tile" d="M0 0H100V100H0Z"/>
<clipPath id="clip"><rect x="20" y="20" width="60" height="60"/></clipPath>
</defs>
<g clip-path="url(#clip)"><use href="#tile" fill="#ff0000"/></g>
</svg>`
})) as { id: string }
const clipFrame = expectDefined(graph.getChildren(result.id)[0])
const clippedChildren = graph.getChildren(clipFrame.id)
expect(clippedChildren).toHaveLength(2)
expect(clippedChildren[0].isMask).toBe(true)
expect(clippedChildren[1].fills[0].color.r).toBeCloseTo(1)
})
test('applies nested clip paths from outermost to innermost', async () => {
const result = (await importSVG.execute(figma, {
svg: `<svg viewBox="0 0 100 100">
<defs>
<clipPath id="outer"><rect x="10" y="10" width="80" height="80"/></clipPath>
<clipPath id="inner"><circle cx="50" cy="50" r="25"/></clipPath>
</defs>
<g clip-path="url(#outer)">
<rect width="100" height="100" fill="#ff0000" clip-path="url(#inner)"/>
</g>
</svg>`
})) as { id: string }
const outerFrame = expectDefined(graph.getChildren(result.id)[0])
const outerChildren = graph.getChildren(outerFrame.id)
expect(outerChildren[0].isMask).toBe(true)
const innerFrame = expectDefined(outerChildren[1])
expect(innerFrame.type).toBe('FRAME')
const innerChildren = graph.getChildren(innerFrame.id)
expect(innerChildren[0].isMask).toBe(true)
expect(innerChildren[1].type).toBe('VECTOR')
})
test('maps objectBoundingBox clip paths to each painted path bounds', async () => {
const result = (await importSVG.execute(figma, {
svg: `<svg viewBox="0 0 200 100">
<defs>
<clipPath id="half" clipPathUnits="objectBoundingBox">
<rect width="0.5" height="1"/>
</clipPath>
</defs>
<rect x="20" y="10" width="60" height="80" fill="#ff0000" clip-path="url(#half)"/>
<rect x="120" y="20" width="40" height="60" fill="#0000ff" clip-path="url(#half)"/>
</svg>`
})) as { id: string }
const [leftFrame, rightFrame] = graph.getChildren(result.id)
const leftMask = expectDefined(graph.getChildren(expectDefined(leftFrame).id)[0])
const rightMask = expectDefined(graph.getChildren(expectDefined(rightFrame).id)[0])
expect(leftMask.x).toBeCloseTo(20)
expect(leftMask.y).toBeCloseTo(10)
expect(leftMask.width).toBeCloseTo(30)
expect(leftMask.height).toBeCloseTo(80)
expect(rightMask.x).toBeCloseTo(120)
expect(rightMask.y).toBeCloseTo(20)
expect(rightMask.width).toBeCloseTo(20)
expect(rightMask.height).toBeCloseTo(60)
})
test('imports gradient fills through the shared SVG pipeline', async () => {
const result = (await importSVG.execute(figma, {
svg: `<svg viewBox="0 0 10 10"><defs><linearGradient id="g"><stop offset="0" stop-color="#000"/><stop offset="1" stop-color="#fff"/></linearGradient></defs><rect width="10" height="10" fill="url(#g)"/></svg>`