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:
parent
3e4e3d0b8b
commit
767fd99493
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) : {})
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
42
packages/core/tests/icons/render.test.ts
Normal file
42
packages/core/tests/icons/render.test.ts
Normal 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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
35
packages/core/tests/vector/vectorize/placement.test.ts
Normal file
35
packages/core/tests/vector/vectorize/placement.test.ts
Normal 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' }
|
||||
])
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue