fix(core): keep SVG icon stroke caps and joins after saving (#875)

* 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.

* 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>
This commit is contained in:
Danila Poyarkov 2026-10-04 12:44:45 +00:00 committed by GitHub
parent 3e4e3d0b8b
commit 767fd99493
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 99 additions and 6 deletions

View file

@ -94,6 +94,7 @@
- Load the Bold, Medium, and other styles of macOS system fonts packaged as font collections, such as Menlo, Helvetica Neue, and Avenir Next, instead of reporting them as substituted or drawing a different style (#746).
- Load the Medium, Semibold, Bold, and other styles of installed variable fonts such as SF Pro on macOS instead of reporting them as substituted (#752).
- Ship the MIT license text in every published npm package, and add READMEs for `@open-pencil/core`, `@open-pencil/cli`, and `@open-pencil/mcp` on npm.
- Keep round and other stroke caps and joins after saving and reopening the file on icons from `insert_icon` and Design JSX `<Icon>`, and on vectors from inline Design JSX `<svg>`, `import_svg`, and dropped or pasted SVG files. They were set only on the stroke paint, which `.fig` does not store, so outline icons such as Lucide's reopened with butt caps and miter joins and showed gaps where their strokes meet.
### Performance

View file

@ -30,3 +30,11 @@ export function createPathStroke(
join: STROKE_JOIN_MAP[strokeJoin] ?? 'MITER'
}
}
/**
* Node-level cap and join for an SVG path stroke. `.fig` files store these on the node, not on the
* stroke paint, so a vector that sets only `Stroke.cap`/`Stroke.join` loses them when reopened.
*/
export function pathStrokeLineStyle(stroke: Stroke): Pick<SceneNode, 'strokeCap' | 'strokeJoin'> {
return { strokeCap: stroke.cap ?? 'NONE', strokeJoin: stroke.join ?? 'MITER' }
}

View file

@ -2,7 +2,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import type { Color } from '@open-pencil/scene-graph/primitives'
import { createPathStroke } from '#core/icons/path-style'
import { createPathStroke, pathStrokeLineStyle } from '#core/icons/path-style'
import type { IconData } from './types'
@ -44,9 +44,13 @@ export function createIconFromPaths(
if (path.stroke) {
const strokeColor = path.stroke === 'currentColor' ? color : parseColor(path.stroke)
graph.updateNode(vector.id, {
strokes: [createPathStroke(strokeColor, path.strokeWidth, path.strokeCap, path.strokeJoin)]
})
const stroke = createPathStroke(
strokeColor,
path.strokeWidth,
path.strokeCap,
path.strokeJoin
)
graph.updateNode(vector.id, { strokes: [stroke], ...pathStrokeLineStyle(stroke) })
}
}

View file

@ -8,6 +8,7 @@ import type {
import { mergeVectorNetworks } from '@open-pencil/scene-graph'
import type { Rect } from '@open-pencil/scene-graph/primitives'
import { pathStrokeLineStyle } from '#core/icons/path-style'
import { computeAccurateBounds } from '#core/vector/curve-math'
import { regenerateFillGeometry } from '#core/vector/fill-geometry'
@ -28,7 +29,7 @@ interface NormalizedVectorGeometry {
}
type VectorChildPaints = Pick<SceneNode, 'fillGeometry' | 'fills' | 'strokes'> &
Partial<Pick<SceneNode, 'isMask' | 'maskType'>>
Partial<Pick<SceneNode, 'isMask' | 'maskType' | 'strokeCap' | 'strokeJoin'>>
function shouldTightenToContent(
node: Pick<SceneNode, 'width' | 'height' | 'rotation'>,
@ -126,10 +127,12 @@ function createVectorChild(
const inFrame = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY)
const normalized = normalizeVectorToNodeBounds(inFrame)
if (!normalized) return
const stroke = path.strokes.at(0)
createNormalizedVectorChild(graph, frameId, normalized, index, {
fillGeometry: [],
fills: path.fills,
strokes: path.strokes
strokes: path.strokes,
...(stroke ? pathStrokeLineStyle(stroke) : {})
})
}

View file

@ -0,0 +1,42 @@
import { describe, expect, test } from 'bun:test'
import { expectDefined } from '#core-tests/helpers/assert'
import { exportFigFile, parseFigFile } from '@open-pencil/core/io'
import { SceneGraph } from '@open-pencil/scene-graph'
import { parseColor } from '@open-pencil/scene-graph/color'
import { createIconFromPaths } from '#core/icons/render'
import { buildIconData } from '#core/icons/svg'
const BLACK = parseColor('#000000')
function insertIcon(graph: SceneGraph, body: string, viewBox = 24, size = 24) {
const icon = buildIconData({ body }, 'test', 'icon', viewBox, viewBox, size)
const page = graph.getPages()[0]
const frame = createIconFromPaths(graph, icon, 'test:icon', size, BLACK, page.id)
return { page, frame, vectors: graph.getChildren(frame.id) }
}
describe('createIconFromPaths', () => {
test('keeps round stroke caps and joins after a .fig round trip', async () => {
const graph = new SceneGraph()
insertIcon(
graph,
'<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 9l6 6l6-6"/>'
)
const bytes = await exportFigFile(graph)
const reopened = await parseFigFile(bytes.buffer as ArrayBuffer)
const vector = expectDefined(
[...reopened.nodes.values()].find((node) => node.type === 'VECTOR'),
'reopened icon vector'
)
expect(vector.strokeCap).toBe('ROUND')
expect(vector.strokeJoin).toBe('ROUND')
expect(vector.strokes.map(({ cap, join }) => ({ cap, join }))).toEqual([
{ cap: 'ROUND', join: 'ROUND' }
])
})
})

View file

@ -0,0 +1,35 @@
import { describe, expect, test } from 'bun:test'
import { expectDefined } from '#core-tests/helpers/assert'
import { FigmaAPI } from '@open-pencil/core'
import { exportFigFile, parseFigFile } from '@open-pencil/core/io'
import { importSVG } from '@open-pencil/core/tools'
import { SceneGraph } from '@open-pencil/scene-graph'
async function importVectors(svg: string) {
const graph = new SceneGraph()
await importSVG.execute(new FigmaAPI(graph), { svg })
return { graph, vectors: [...graph.nodes.values()].filter((node) => node.type === 'VECTOR') }
}
describe('SVG vector placement', () => {
test('keeps stroke caps and joins after a .fig round trip', async () => {
const { graph } = await importVectors(
'<svg viewBox="0 0 24 24"><path d="M8 2v4m8-4v4" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="bevel"/></svg>'
)
const bytes = await exportFigFile(graph)
const reopened = await parseFigFile(bytes.buffer as ArrayBuffer)
const vector = expectDefined(
[...reopened.nodes.values()].find((node) => node.type === 'VECTOR'),
'reopened vector'
)
expect(vector.strokeCap).toBe('ROUND')
expect(vector.strokeJoin).toBe('BEVEL')
expect(vector.strokes.map(({ cap, join }) => ({ cap, join }))).toEqual([
{ cap: 'ROUND', join: 'BEVEL' }
])
})
})