2026-06-06 19:24:31 +00:00
|
|
|
import svgpath from 'svgpath'
|
|
|
|
|
|
|
|
|
|
import type { VectorNetwork, VectorVertex, VectorSegment, VectorRegion, WindingRule } from './index'
|
|
|
|
|
|
|
|
|
|
interface SubPath {
|
|
|
|
|
startVertexIndex: number
|
|
|
|
|
segmentIndices: number[]
|
|
|
|
|
closed: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:32:16 +00:00
|
|
|
export type VectorPathWindingRule = WindingRule | 'NONE'
|
|
|
|
|
export type SVGPathParseResult = { ok: true; network: VectorNetwork } | { ok: false; error: string }
|
|
|
|
|
|
|
|
|
|
interface ParseOptions {
|
|
|
|
|
includeOpenRegions: boolean
|
|
|
|
|
strictCommands: boolean
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-06 19:24:31 +00:00
|
|
|
/**
|
|
|
|
|
* Parse an SVG path `d` attribute into a VectorNetwork.
|
|
|
|
|
*
|
|
|
|
|
* Uses `svgpath` to normalize all commands to absolute M/L/C/Z
|
|
|
|
|
* (arcs → cubics via `.unarc()`, smooth curves → explicit via `.unshort()`).
|
fix(core): fill open subpaths of filled SVG paths (#876)
* fix(core): keep SVG icon stroke caps and joins after saving
Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.
* fix(core): fill open subpaths of filled SVG paths
SVG fills every subpath as if it were closed, but parseSVGPath put only
closed subpaths in the fill region. The renderer filled the open ones as
a separate path, so a hole formed by an open subpath and a closed one
under the path's fill rule was filled in, as in some Font Awesome icons.
Add an includeOpenRegions option to parseSVGPath, off by default, and set
it for filled paths without a stroke from Iconify icons and import_svg,
and for SVG clip paths. Stroked paths keep open subpaths out of the
region so their closing edges are not stroked. A filled polyline now
flattens with adjacent filled shapes like a polygon does.
* docs(changelog): state where open SVG subpaths are now filled
The app has no icon picker. The fix reaches Design JSX <Icon> and
inline <svg> through scalePathInfos, dropped and pasted SVG files and
clip paths through svgToVectorPaths, and makes filled polylines render
filled. Name the unstroked-path limit instead of an unqualified claim.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it for
compatibility. importVectors also returns the graph, matching the same
helper in #832 so the shared test files reconcile cleanly.
* docs(changelog): name every path that keeps SVG stroke caps
The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.
* refactor(core): read the first SVG path stroke with at(0)
Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:25:23 +00:00
|
|
|
*
|
|
|
|
|
* Only closed subpaths form the fill region unless `includeOpenRegions` is set. SVG fills every
|
|
|
|
|
* subpath as if it were closed, so set it for a filled path to keep holes that an open subpath and
|
|
|
|
|
* a closed one cut together under the path's fill rule.
|
2026-06-06 19:24:31 +00:00
|
|
|
*/
|
fix(core): fill open subpaths of filled SVG paths (#876)
* fix(core): keep SVG icon stroke caps and joins after saving
Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.
* fix(core): fill open subpaths of filled SVG paths
SVG fills every subpath as if it were closed, but parseSVGPath put only
closed subpaths in the fill region. The renderer filled the open ones as
a separate path, so a hole formed by an open subpath and a closed one
under the path's fill rule was filled in, as in some Font Awesome icons.
Add an includeOpenRegions option to parseSVGPath, off by default, and set
it for filled paths without a stroke from Iconify icons and import_svg,
and for SVG clip paths. Stroked paths keep open subpaths out of the
region so their closing edges are not stroked. A filled polyline now
flattens with adjacent filled shapes like a polygon does.
* docs(changelog): state where open SVG subpaths are now filled
The app has no icon picker. The fix reaches Design JSX <Icon> and
inline <svg> through scalePathInfos, dropped and pasted SVG files and
clip paths through svgToVectorPaths, and makes filled polylines render
filled. Name the unstroked-path limit instead of an unqualified claim.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it for
compatibility. importVectors also returns the graph, matching the same
helper in #832 so the shared test files reconcile cleanly.
* docs(changelog): name every path that keeps SVG stroke caps
The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.
* refactor(core): read the first SVG path stroke with at(0)
Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:25:23 +00:00
|
|
|
export function parseSVGPath(
|
|
|
|
|
d: string,
|
|
|
|
|
windingRule: WindingRule = 'NONZERO',
|
|
|
|
|
options: { includeOpenRegions?: boolean } = {}
|
|
|
|
|
): VectorNetwork {
|
2026-08-02 19:32:16 +00:00
|
|
|
const parsed = parsePath(d, windingRule, {
|
fix(core): fill open subpaths of filled SVG paths (#876)
* fix(core): keep SVG icon stroke caps and joins after saving
Icons inserted from Iconify and vectors from import_svg set stroke-linecap
and stroke-linejoin only on the Stroke paint. .fig stores cap and join on
the node, and the reader rebuilds the paint's cap and join from it, so a
saved and reopened Lucide icon came back with NONE caps and MITER joins
and showed gaps where its strokes meet. Set strokeCap and strokeJoin on
the node as well.
* fix(core): fill open subpaths of filled SVG paths
SVG fills every subpath as if it were closed, but parseSVGPath put only
closed subpaths in the fill region. The renderer filled the open ones as
a separate path, so a hole formed by an open subpath and a closed one
under the path's fill rule was filled in, as in some Font Awesome icons.
Add an includeOpenRegions option to parseSVGPath, off by default, and set
it for filled paths without a stroke from Iconify icons and import_svg,
and for SVG clip paths. Stroked paths keep open subpaths out of the
region so their closing edges are not stroked. A filled polyline now
flattens with adjacent filled shapes like a polygon does.
* docs(changelog): state where open SVG subpaths are now filled
The app has no icon picker. The fix reaches Design JSX <Icon> and
inline <svg> through scalePathInfos, dropped and pasted SVG files and
clip paths through svgToVectorPaths, and makes filled polylines render
filled. Name the unstroked-path limit instead of an unqualified claim.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it for
compatibility. importVectors also returns the graph, matching the same
helper in #832 so the shared test files reconcile cleanly.
* docs(changelog): name every path that keeps SVG stroke caps
The app has no icon picker; the fix reaches Design JSX <Icon> and
inline <svg> through createIconFromPaths, and dropped or pasted SVG
files through the same vector placement as import_svg.
* test(core): import SceneGraph from its owning package
Scene Graph owns the graph type; the Core barrel only re-exports it
for compatibility.
* refactor(core): read the first SVG path stroke with at(0)
Array destructuring types the first stroke as always present, so the
type-aware no-unnecessary-condition lint rejected the guard that skips
vectors without strokes and failed bun run check.
---------
Co-authored-by: Jason Woltje <1139190+jetrich@users.noreply.github.com>
2026-10-04 13:25:23 +00:00
|
|
|
includeOpenRegions: options.includeOpenRegions ?? false,
|
2026-08-02 19:32:16 +00:00
|
|
|
strictCommands: false
|
|
|
|
|
})
|
|
|
|
|
return parsed.ok ? parsed.network : { vertices: [], segments: [], regions: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Parse the strict absolute command subset accepted by Figma's VectorPath API. */
|
|
|
|
|
export function parsePluginVectorPath(
|
|
|
|
|
d: string,
|
|
|
|
|
windingRule: VectorPathWindingRule
|
|
|
|
|
): SVGPathParseResult {
|
|
|
|
|
return parsePath(d, windingRule, {
|
|
|
|
|
includeOpenRegions: windingRule !== 'NONE',
|
|
|
|
|
strictCommands: true
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parsePath(
|
|
|
|
|
d: string,
|
|
|
|
|
windingRule: VectorPathWindingRule,
|
|
|
|
|
options: ParseOptions
|
|
|
|
|
): SVGPathParseResult {
|
2026-06-06 19:24:31 +00:00
|
|
|
const vertices: VectorVertex[] = []
|
|
|
|
|
const segments: VectorSegment[] = []
|
|
|
|
|
const subPaths: SubPath[] = []
|
|
|
|
|
|
|
|
|
|
let currentSubPath: SubPath | null = null
|
|
|
|
|
let cx = 0
|
|
|
|
|
let cy = 0
|
|
|
|
|
|
|
|
|
|
const vertexMap = new Map<string, number>()
|
|
|
|
|
|
|
|
|
|
function getOrCreateVertex(x: number, y: number): number {
|
|
|
|
|
const key = `${x},${y}`
|
|
|
|
|
const existing = vertexMap.get(key)
|
|
|
|
|
if (existing !== undefined) return existing
|
|
|
|
|
const idx = vertices.length
|
|
|
|
|
vertices.push({ x, y })
|
|
|
|
|
vertexMap.set(key, idx)
|
|
|
|
|
return idx
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addSegment(
|
|
|
|
|
x1: number,
|
|
|
|
|
y1: number,
|
|
|
|
|
x2: number,
|
|
|
|
|
y2: number,
|
|
|
|
|
tx1: number,
|
|
|
|
|
ty1: number,
|
|
|
|
|
tx2: number,
|
|
|
|
|
ty2: number
|
|
|
|
|
): void {
|
|
|
|
|
const startIdx = getOrCreateVertex(x1, y1)
|
|
|
|
|
const endIdx = getOrCreateVertex(x2, y2)
|
|
|
|
|
const segIdx = segments.length
|
|
|
|
|
segments.push({
|
|
|
|
|
start: startIdx,
|
|
|
|
|
end: endIdx,
|
|
|
|
|
tangentStart: { x: tx1 - x1, y: ty1 - y1 },
|
|
|
|
|
tangentEnd: { x: tx2 - x2, y: ty2 - y2 }
|
|
|
|
|
})
|
|
|
|
|
if (currentSubPath) {
|
|
|
|
|
currentSubPath.segmentIndices.push(segIdx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addLine(x1: number, y1: number, x2: number, y2: number): void {
|
|
|
|
|
addSegment(x1, y1, x2, y2, x1, y1, x2, y2)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addCubic(
|
|
|
|
|
x1: number,
|
|
|
|
|
y1: number,
|
|
|
|
|
cp1x: number,
|
|
|
|
|
cp1y: number,
|
|
|
|
|
cp2x: number,
|
|
|
|
|
cp2y: number,
|
|
|
|
|
x2: number,
|
|
|
|
|
y2: number
|
|
|
|
|
): void {
|
|
|
|
|
addSegment(x1, y1, x2, y2, cp1x, cp1y, cp2x, cp2y)
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-01 17:17:42 +00:00
|
|
|
const parsed = svgpath(d)
|
2026-08-02 19:32:16 +00:00
|
|
|
const parseError = 'err' in parsed && typeof parsed.err === 'string' ? parsed.err : null
|
|
|
|
|
if (parseError) return { ok: false, error: parseError }
|
|
|
|
|
if (options.strictCommands) {
|
|
|
|
|
const unsupportedCommands = new Set<string>()
|
|
|
|
|
parsed.iterate((segment) => {
|
|
|
|
|
if (!['M', 'L', 'Q', 'C', 'Z'].includes(segment[0])) unsupportedCommands.add(segment[0])
|
|
|
|
|
})
|
|
|
|
|
const unsupportedCommand = unsupportedCommands.values().next().value
|
|
|
|
|
if (unsupportedCommand !== undefined) {
|
|
|
|
|
return { ok: false, error: `Unsupported path command ${unsupportedCommand}` }
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-08-01 17:17:42 +00:00
|
|
|
const normalized = parsed.abs().unshort().unarc()
|
2026-06-06 19:24:31 +00:00
|
|
|
|
|
|
|
|
normalized.iterate((seg) => {
|
|
|
|
|
const cmd = seg[0]
|
|
|
|
|
|
|
|
|
|
if (cmd === 'M') {
|
|
|
|
|
cx = seg[1]
|
|
|
|
|
cy = seg[2]
|
|
|
|
|
currentSubPath = {
|
|
|
|
|
startVertexIndex: getOrCreateVertex(cx, cy),
|
|
|
|
|
segmentIndices: [],
|
|
|
|
|
closed: false
|
|
|
|
|
}
|
|
|
|
|
subPaths.push(currentSubPath)
|
|
|
|
|
} else if (cmd === 'L') {
|
|
|
|
|
addLine(cx, cy, seg[1], seg[2])
|
|
|
|
|
cx = seg[1]
|
|
|
|
|
cy = seg[2]
|
|
|
|
|
} else if (cmd === 'H') {
|
|
|
|
|
addLine(cx, cy, seg[1], cy)
|
|
|
|
|
cx = seg[1]
|
|
|
|
|
} else if (cmd === 'V') {
|
|
|
|
|
addLine(cx, cy, cx, seg[1])
|
|
|
|
|
cy = seg[1]
|
|
|
|
|
} else if (cmd === 'C') {
|
|
|
|
|
addCubic(cx, cy, seg[1], seg[2], seg[3], seg[4], seg[5], seg[6])
|
|
|
|
|
cx = seg[5]
|
|
|
|
|
cy = seg[6]
|
|
|
|
|
} else if (cmd === 'Q') {
|
|
|
|
|
const qx = seg[1]
|
|
|
|
|
const qy = seg[2]
|
|
|
|
|
const ex = seg[3]
|
|
|
|
|
const ey = seg[4]
|
|
|
|
|
const cp1x = cx + (2 / 3) * (qx - cx)
|
|
|
|
|
const cp1y = cy + (2 / 3) * (qy - cy)
|
|
|
|
|
const cp2x = ex + (2 / 3) * (qx - ex)
|
|
|
|
|
const cp2y = ey + (2 / 3) * (qy - ey)
|
|
|
|
|
addCubic(cx, cy, cp1x, cp1y, cp2x, cp2y, ex, ey)
|
|
|
|
|
cx = ex
|
|
|
|
|
cy = ey
|
|
|
|
|
} else if (cmd === 'Z' || cmd === 'z') {
|
|
|
|
|
if (currentSubPath) {
|
|
|
|
|
const startVert = vertices[currentSubPath.startVertexIndex]
|
|
|
|
|
if (Math.abs(cx - startVert.x) > 0.001 || Math.abs(cy - startVert.y) > 0.001) {
|
|
|
|
|
addLine(cx, cy, startVert.x, startVert.y)
|
|
|
|
|
}
|
|
|
|
|
currentSubPath.closed = true
|
|
|
|
|
cx = startVert.x
|
|
|
|
|
cy = startVert.y
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const regions: VectorRegion[] = []
|
2026-08-02 19:32:16 +00:00
|
|
|
const regionPaths = subPaths.filter(
|
|
|
|
|
(subPath) =>
|
|
|
|
|
windingRule !== 'NONE' &&
|
|
|
|
|
subPath.segmentIndices.length > 0 &&
|
|
|
|
|
(subPath.closed || options.includeOpenRegions)
|
|
|
|
|
)
|
|
|
|
|
if (regionPaths.length > 0 && windingRule !== 'NONE') {
|
2026-06-06 19:24:31 +00:00
|
|
|
regions.push({
|
|
|
|
|
windingRule,
|
2026-08-02 19:32:16 +00:00
|
|
|
loops: regionPaths.map((subPath) => subPath.segmentIndices)
|
2026-06-06 19:24:31 +00:00
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-02 19:32:16 +00:00
|
|
|
return { ok: true, network: { vertices, segments, regions } }
|
2026-06-06 19:24:31 +00:00
|
|
|
}
|