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:
parent
742522a692
commit
bcf361f45d
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>`
|
||||
|
|
|
|||
Loading…
Reference in a new issue