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' } + ]) + }) +})