fix(io): handle bounded and nested SVG clips

- Carry inherited clip metadata through use expansions
- Map objectBoundingBox clips per painted path bounds
- Apply nested clip regions as ordered vector mask frames
This commit is contained in:
Danila Poyarkov 2026-08-14 13:13:29 +03:00
parent 742522a692
commit bcf361f45d
5 changed files with 139 additions and 33 deletions

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, SVGClipPathInfo } from './types'
import type { IconData, IconifyIconEntry, IconPathInfo, SVGClipPathRegion } from './types'
interface SVGElementInput {
type: string
@ -185,7 +185,7 @@ function appendShapePath(
element: Element,
presentation: PresentationAttributes,
transform: string | null,
clipPaths: SVGClipPathInfo[][],
clipPaths: SVGClipPathRegion[],
result: IconPathInfo[]
): void {
if (!SHAPE_NAMES.has(tagName)) return
@ -211,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
@ -229,32 +230,40 @@ function collectUsePaths(
result,
elementsById,
new Set([...useStack, target]),
true
true,
clipPaths
)
}
return true
}
function collectClipPaths(
function collectClipPath(
value: string | null,
parentTransform: string | null,
elementsById: ReadonlyMap<string, Element>
): SVGClipPathInfo[] {
): 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 []
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' },
parentTransform,
units === 'objectBoundingBox' ? null : parentTransform,
paths,
elementsById,
new Set([target]),
true
)
return paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform }))
return {
paths: paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })),
units
}
}
function collectPaths(
@ -265,17 +274,17 @@ function collectPaths(
elementsById: ReadonlyMap<string, Element>,
useStack: ReadonlySet<Element> = new Set(),
referenced = false,
inheritedClipPaths: SVGClipPathInfo[][] = []
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)
const ownClipPaths = collectClipPaths(element.getAttribute('clip-path'), transform, elementsById)
const clipPaths =
ownClipPaths.length > 0 ? [...inheritedClipPaths, ownClipPaths] : inheritedClipPaths
if (collectUsePaths(element, presentation, transform, result, elementsById, useStack)) return
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)) {

View file

@ -46,9 +46,14 @@ export interface IconPathInfo {
strokeJoin: string
fillRule: WindingRule
/** Nested SVG clip regions, ordered from outermost to innermost. */
clipPaths?: SVGClipPathInfo[][]
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

@ -249,6 +249,22 @@ function createClipMaskChild(
})
}
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
}
@ -261,15 +277,12 @@ export function createFlattenedVectorFrameChildren(
placement: VectorFramePlacement
): void {
let run: { path: VectorizedPath; index: number }[] = []
let runClipNetwork: VectorNetwork | undefined
let runClipNetworks: VectorNetwork[] | undefined
const flush = () => {
if (run.length === 0) return
const targetFrameId = runClipNetwork
? createClipFrame(graph, frameId, placement, run[0].index).id
const targetFrameId = runClipNetworks
? createClipFrames(graph, frameId, runClipNetworks, placement, run[0].index)
: frameId
if (runClipNetwork) {
createClipMaskChild(graph, targetFrameId, runClipNetwork, placement, run[0].index)
}
if (run.length > 1) {
createFlattenedVectorChild(
graph,
@ -282,22 +295,21 @@ export function createFlattenedVectorFrameChildren(
createVectorChild(graph, targetFrameId, run[0].path, placement, run[0].index)
}
run = []
runClipNetwork = undefined
runClipNetworks = undefined
}
for (const [index, path] of vectorized.paths.entries()) {
const clipNetwork = path.clipNetworks?.at(-1)
const clipNetworks = path.clipNetworks
if (isFlattenableVectorPath(path)) {
if (run.length > 0 && runClipNetwork !== clipNetwork) flush()
runClipNetwork = clipNetwork
if (run.length > 0 && runClipNetworks !== clipNetworks) flush()
runClipNetworks = clipNetworks
run.push({ path, index })
continue
}
flush()
if (clipNetwork) {
const clipFrame = createClipFrame(graph, frameId, placement, index)
createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index)
createVectorChild(graph, clipFrame.id, 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)
}

View file

@ -5,6 +5,8 @@
* 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'
@ -98,6 +100,7 @@ export function svgToVectorPaths(
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(
@ -110,17 +113,27 @@ export function svgToVectorPaths(
: null
let clipNetworks: VectorNetwork[] | undefined
if (path.clipPaths) {
clipNetworks = clipCache.get(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.map((clipPath) => {
const clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null)
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)
})
)
)
clipCache.set(path.clipPaths, clipNetworks)
if (!hasObjectBoundingBoxClip) clipCache.set(path.clipPaths, clipNetworks)
}
}
vectorized.push({

View file

@ -289,6 +289,73 @@ describe('import_svg', () => {
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>`