Merge pull request #513 from open-pencil/fix-svg-clip-path-import
fix(io): preserve SVG clip paths
This commit is contained in:
commit
dd8bb7aa5c
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
Loading…
Reference in a new issue