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: ``