diff --git a/CHANGELOG.md b/CHANGELOG.md index bc9aa4ff2..dfd4e0e24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, 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..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 } 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, - useStack: ReadonlySet + useStack: ReadonlySet, + 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 +): 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, useStack: ReadonlySet = 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 + ) } } } diff --git a/packages/core/src/icons/types.ts b/packages/core/src/icons/types.ts index e8df7c89b..e2c1471f5 100644 --- a/packages/core/src/icons/types.ts +++ b/packages/core/src/icons/types.ts @@ -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 + +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 61750c2c7..3aa4069e3 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,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() } diff --git a/packages/core/src/vector/vectorize/svg/to-vectors.ts b/packages/core/src/vector/vectorize/svg/to-vectors.ts index ae8a18969..16268ed17 100644 --- a/packages/core/src/vector/vectorize/svg/to-vectors.ts +++ b/packages/core/src/vector/vectorize/svg/to-vectors.ts @@ -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, 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 }) } diff --git a/tests/engine/io/svg/import.test.ts b/tests/engine/io/svg/import.test.ts index 448dbedd9..c2a4c1000 100644 --- a/tests/engine/io/svg/import.test.ts +++ b/tests/engine/io/svg/import.test.ts @@ -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: ` + + + + + + + + ` + })) 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: ` + + + + + + ` + })) 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: ``