* 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>
91 lines
2.9 KiB
TypeScript
91 lines
2.9 KiB
TypeScript
import { describe, expect, test } from 'bun:test'
|
|
|
|
import { expectDefined } from '#core-tests/helpers/assert'
|
|
|
|
import { SkiaRenderer } from '@open-pencil/core'
|
|
import {
|
|
exportFigFile,
|
|
initCanvasKit,
|
|
parseFigFile,
|
|
renderNodesToImage
|
|
} 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' }
|
|
])
|
|
})
|
|
|
|
test('fills open subpaths of a filled path together with its closed subpaths', async () => {
|
|
const graph = new SceneGraph()
|
|
// An open outer square around a closed, counter-wound inner square: a 24 px ring.
|
|
const { page, frame } = insertIcon(
|
|
graph,
|
|
'<path fill="currentColor" d="M0 0h24v24H0M6 6v12h12V6z"/>'
|
|
)
|
|
|
|
const ck = await initCanvasKit()
|
|
const renderer = new SkiaRenderer(ck, expectDefined(ck.MakeSurface(1, 1)))
|
|
try {
|
|
const png = expectDefined(
|
|
renderNodesToImage(ck, renderer, graph, page.id, [frame.id], {
|
|
scale: 1,
|
|
format: 'PNG',
|
|
trimTransparent: false
|
|
})
|
|
)
|
|
const image = expectDefined(ck.MakeImageFromEncoded(png))
|
|
const alphaAt = (x: number, y: number) => {
|
|
const pixel = expectDefined(
|
|
image.readPixels(x, y, {
|
|
width: 1,
|
|
height: 1,
|
|
alphaType: ck.AlphaType.Unpremul,
|
|
colorType: ck.ColorType.RGBA_8888,
|
|
colorSpace: ck.ColorSpace.SRGB
|
|
})
|
|
)
|
|
return pixel[3]
|
|
}
|
|
try {
|
|
expect(alphaAt(3, 3)).toBe(255)
|
|
expect(alphaAt(12, 12)).toBe(0)
|
|
} finally {
|
|
image.delete()
|
|
}
|
|
} finally {
|
|
renderer.destroy()
|
|
}
|
|
})
|
|
})
|