From 767fd99493f5b34c31f805f5cd7ca893a30e0dd5 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 4 Oct 2026 12:44:45 +0000 Subject: [PATCH] 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 and inline 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> --- CHANGELOG.md | 1 + packages/core/src/icons/path-style.ts | 8 ++++ packages/core/src/icons/render.ts | 12 ++++-- .../core/src/vector/vectorize/placement.ts | 7 +++- packages/core/tests/icons/render.test.ts | 42 +++++++++++++++++++ .../tests/vector/vectorize/placement.test.ts | 35 ++++++++++++++++ 6 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 packages/core/tests/icons/render.test.ts create mode 100644 packages/core/tests/vector/vectorize/placement.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0697a7818..2b1ef392b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ``, and on vectors from inline Design JSX ``, `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 diff --git a/packages/core/src/icons/path-style.ts b/packages/core/src/icons/path-style.ts index 4954ee74e..8ee7a88b8 100644 --- a/packages/core/src/icons/path-style.ts +++ b/packages/core/src/icons/path-style.ts @@ -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 { + return { strokeCap: stroke.cap ?? 'NONE', strokeJoin: stroke.join ?? 'MITER' } +} diff --git a/packages/core/src/icons/render.ts b/packages/core/src/icons/render.ts index d994335a7..46226ca4b 100644 --- a/packages/core/src/icons/render.ts +++ b/packages/core/src/icons/render.ts @@ -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) }) } } diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 3aa4069e3..a989c94a3 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -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 & - Partial> + Partial> function shouldTightenToContent( node: Pick, @@ -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) : {}) }) } diff --git a/packages/core/tests/icons/render.test.ts b/packages/core/tests/icons/render.test.ts new file mode 100644 index 000000000..3372d3223 --- /dev/null +++ b/packages/core/tests/icons/render.test.ts @@ -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, + '' + ) + + 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' } + ]) + }) +}) diff --git a/packages/core/tests/vector/vectorize/placement.test.ts b/packages/core/tests/vector/vectorize/placement.test.ts new file mode 100644 index 000000000..c0401355c --- /dev/null +++ b/packages/core/tests/vector/vectorize/placement.test.ts @@ -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( + '' + ) + + 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' } + ]) + }) +})