From 742522a69254271b6518bcf44d848ccc30513eba Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 12:56:13 +0300 Subject: [PATCH 1/2] fix(io): preserve SVG clip paths - Resolve clipPath geometry referenced through use elements - Import clipped paint runs as editable mask groups - Cover clipped multicolor SVG imports with a regression test --- CHANGELOG.md | 1 + packages/core/src/icons/svg.ts | 47 +++++++++++-- packages/core/src/icons/types.ts | 4 ++ .../core/src/vector/vectorize/placement.ts | 69 +++++++++++++++++-- .../src/vector/vectorize/svg/to-vectors.ts | 21 +++++- tests/engine/io/svg/import.test.ts | 26 +++++++ 6 files changed, 158 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1049eac36..0918ec118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,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 ``, 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 diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 7ab25ad60..2325eb07b 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -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, SVGClipPathInfo } from './types' interface SVGElementInput { type: string @@ -185,6 +185,7 @@ function appendShapePath( element: Element, presentation: PresentationAttributes, transform: string | null, + clipPaths: SVGClipPathInfo[][], 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 }) } @@ -233,6 +235,28 @@ function collectUsePaths( return true } +function collectClipPaths( + value: string | null, + parentTransform: string | null, + elementsById: ReadonlyMap +): SVGClipPathInfo[] { + 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 [] + + const paths: IconPathInfo[] = [] + collectPaths( + target, + { ...DEFAULT_PRESENTATION, fill: '#000000' }, + parentTransform, + paths, + elementsById, + new Set([target]), + true + ) + return paths.map(({ d, fillRule, transform }) => ({ d, fillRule, transform })) +} + function collectPaths( element: Element, inherited: PresentationAttributes, @@ -240,19 +264,32 @@ function collectPaths( result: IconPathInfo[], elementsById: ReadonlyMap, useStack: ReadonlySet = new Set(), - referenced = false + referenced = false, + inheritedClipPaths: SVGClipPathInfo[][] = [] ): 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 - appendShapePath(tagName, element, presentation, transform, result) + 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 + ) } } } diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index e8df7c89b..299964cab 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -45,6 +45,10 @@ export interface IconPathInfo { strokeCap: string strokeJoin: string fillRule: WindingRule + /** Nested SVG clip regions, ordered from outermost to innermost. */ + clipPaths?: SVGClipPathInfo[][] /** Raw transform attribute from the source SVG element. */ transform?: string | null } + +export type SVGClipPathInfo = Pick diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 61750c2c7..4dbc7a6c3 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -27,7 +27,8 @@ interface NormalizedVectorGeometry { bounds: Rect } -type VectorChildPaints = Pick +type VectorChildPaints = Pick & + Partial> function shouldTightenToContent( node: Pick, @@ -206,6 +207,48 @@ 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 isFlattenableVectorPath(path: VectorizedPath): boolean { return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0 } @@ -218,28 +261,46 @@ export function createFlattenedVectorFrameChildren( placement: VectorFramePlacement ): void { let run: { path: VectorizedPath; index: number }[] = [] + let runClipNetwork: VectorNetwork | undefined const flush = () => { + if (run.length === 0) return + const targetFrameId = runClipNetwork + ? createClipFrame(graph, frameId, placement, run[0].index).id + : frameId + if (runClipNetwork) { + createClipMaskChild(graph, targetFrameId, runClipNetwork, placement, run[0].index) + } 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 = [] + runClipNetwork = undefined } for (const [index, path] of vectorized.paths.entries()) { + const clipNetwork = path.clipNetworks?.at(-1) if (isFlattenableVectorPath(path)) { + if (run.length > 0 && runClipNetwork !== clipNetwork) flush() + runClipNetwork = clipNetwork run.push({ path, index }) continue } flush() - createVectorChild(graph, frameId, path, placement, index) + if (clipNetwork) { + const clipFrame = createClipFrame(graph, frameId, placement, index) + createClipMaskChild(graph, clipFrame.id, clipNetwork, placement, index) + createVectorChild(graph, clipFrame.id, path, placement, index) + } else { + createVectorChild(graph, frameId, path, placement, index) + } } flush() } diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index ae8a18969..fba617445 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -6,6 +6,7 @@ * (viewBox, else width/height) into the target node bounds before parsing. */ 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 +60,7 @@ export interface VectorizedPath { vectorNetwork: VectorNetwork fills: Fill[] strokes: Stroke[] + clipNetworks?: VectorNetwork[] } export interface SVGVectorizeResult { @@ -89,6 +91,7 @@ export function svgToVectorPaths( const strokeScale = Math.min(viewport.scaleX, viewport.scaleY) const vectorized: VectorizedPath[] = [] + const clipCache = new WeakMap, VectorNetwork[]>() for (const path of paths) { const fillRule: WindingRule = path.fillRule const transform = path.transform ?? null @@ -105,10 +108,26 @@ export function svgToVectorPaths( computeAccurateBounds(network) ) : null + let clipNetworks: VectorNetwork[] | undefined + if (path.clipPaths) { + clipNetworks = clipCache.get(path.clipPaths) + if (!clipNetworks) { + clipNetworks = path.clipPaths.map((clipRegion) => + mergeVectorNetworks( + clipRegion.map((clipPath) => { + const clipData = applySVGTransformToPath(clipPath.d, clipPath.transform ?? null) + return parseSVGPath(mapSVGPathToViewport(clipData, viewport), clipPath.fillRule) + }) + ) + ) + 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 }) } diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 448dbedd9..31dc78401 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -263,6 +263,32 @@ 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: ` + + + + + + + + ` + })) 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('imports gradient fills through the shared SVG pipeline', async () => { const result = (await importSVG.execute(figma, { svg: `` From bcf361f45dbaa0556abb4893e038978b4eb8f6a5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Fri, 14 Aug 2026 13:13:29 +0300 Subject: [PATCH 2/2] 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 --- packages/core/src/icons/svg.ts | 37 ++++++---- packages/core/src/icons/types.ts | 7 +- .../core/src/vector/vectorize/placement.ts | 40 +++++++---- .../src/vector/vectorize/svg/to-vectors.ts | 21 ++++-- tests/engine/io/svg/import.test.ts | 67 +++++++++++++++++++ 5 files changed, 139 insertions(+), 33 deletions(-) diff --git a/packages/core/src/icons/svg.ts b/packages/core/src/icons/svg.ts index 2325eb07b..c8b3f5bf4 100644 --- a/packages/core/src/icons/svg.ts +++ b/packages/core/src/icons/svg.ts @@ -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, - useStack: ReadonlySet + useStack: ReadonlySet, + 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 -): 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, useStack: ReadonlySet = 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)) { diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index 299964cab..e2c1471f5 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -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 + +export interface SVGClipPathRegion { + paths: SVGClipPathInfo[] + units: 'userSpaceOnUse' | 'objectBoundingBox' +} diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 4dbc7a6c3..3aa4069e3 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -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) } diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index fba617445..16268ed17 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -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({ diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 31dc78401..c2a4c1000 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -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: ` + + + + + + ` + })) 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: ` + + + + + + + + ` + })) 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: ` + + + + + + + + ` + })) 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: ``