From 1f2f2468cc0bea2e5976870f5aac877323a4d3c2 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 1 Aug 2026 13:42:53 +0300 Subject: [PATCH] feat(svg): flatten compatible imported fills - Merge adjacent filled SVG paths into editable multi-color vector networks - Preserve stroke ordering and remap gradients into flattened bounds - Export path-level solid and gradient fills without losing paint fidelity - Cover real provider artwork, SVG round trips, and CanvasKit output Co-authored-by: Rob Coenen <753704+rcoenen@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/core/src/io/formats/svg/export.ts | 59 +++++- packages/core/src/io/formats/svg/import.ts | 4 +- .../core/src/vector/vectorize/placement.ts | 175 ++++++++++++++++-- packages/scene-graph/src/index.ts | 1 + packages/scene-graph/src/vector-network.ts | 35 ++++ tests/e2e/canvas/drop-files.spec.ts | 4 +- ...opped-svg-multicolor-openpencil-darwin.png | Bin 0 -> 18083 bytes tests/engine/io/svg/import.test.ts | 83 ++++++++- .../engine/scene-graph/vector-network.test.ts | 45 +++++ 10 files changed, 366 insertions(+), 41 deletions(-) create mode 100644 tests/e2e/canvas/drop-files.spec.ts-snapshots/dropped-svg-multicolor-openpencil-darwin.png create mode 100644 tests/engine/scene-graph/vector-network.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74b88a937..dee9eb43f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Use Figma-style number-key opacity shortcuts: `1`–`9` set 10%–90%, `0` sets 100%, and two-digit sequences set exact values. - Drag image files directly into the desktop app and paste Figma layers with their remote image fills. - Drop SVG files onto the canvas to import them as editable vector layers alongside raster images. (#392) +- Import compatible SVG fills as a single editable multi-color vector. (#394) - Drag with the Text tool to create a fixed-size text box, or click to create auto-width text. - Target a specific open document and page from live CLI and MCP automation, including sessions with multiple documents. - Test OpenAI-compatible provider connections from AI settings with clearer setup errors. diff --git a/packages/core/src/io/formats/svg/export.ts b/packages/core/src/io/formats/svg/export.ts index 01b5d67ef..1cbb75944 100644 --- a/packages/core/src/io/formats/svg/export.ts +++ b/packages/core/src/io/formats/svg/export.ts @@ -39,18 +39,38 @@ import type { SVGNode } from './node' function vectorShapeElements( node: SceneNode, common: Record, - strokeAttrs: Record + strokeAttrs: Record, + ctx: SVGExportContext, + fallbackFills?: Fill[] ): SVGNode[] { const elements: SVGNode[] = [] if (node.fillGeometry.length > 0) { for (const geo of node.fillGeometry) { const d = geometryBlobToSVGPath(geo.commandsBlob) - if (d) { + if (!d) continue + const attrs = { + d, + 'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined, + ...common + } + const pathFills = geo.fills?.length ? geo.fills : fallbackFills + if (!pathFills) { + elements.push(svg('path', attrs)) + continue + } + const visibleFills = pathFills.filter((fill) => fill.visible) + if (visibleFills.length === 0) { + elements.push(svg('path', { ...attrs, fill: 'none' })) + continue + } + for (const [index, fill] of visibleFills.entries()) { + const fillAttr = resolveFill(fill, node, ctx) + if (!fillAttr) continue elements.push( svg('path', { - d, - 'fill-rule': geo.windingRule === 'EVENODD' ? 'evenodd' : undefined, - ...common + ...attrs, + fill: fillAttr, + stroke: index === visibleFills.length - 1 ? common.stroke : 'none' }) ) } @@ -84,7 +104,8 @@ function vectorShapeElements( function nodeShapeElements( node: SceneNode, fillAttr: string | null, - strokeAttrs: Record + strokeAttrs: Record, + ctx: SVGExportContext ): SVGNode[] { const common: Record = { fill: fillAttr ?? 'none', @@ -124,7 +145,7 @@ function nodeShapeElements( return [svg('polygon', { points: makePolygonPoints(node), ...common })] case 'VECTOR': - return vectorShapeElements(node, common, strokeAttrs) + return vectorShapeElements(node, common, strokeAttrs, ctx) default: { if (hasRadius(node)) { @@ -311,6 +332,10 @@ function buildSVGStrokeAttrs( return attrs } +function hasPathLevelFills(node: SceneNode): boolean { + return node.type === 'VECTOR' && node.fillGeometry.some((geometry) => geometry.fills?.length) +} + function buildShapeChildren( node: SceneNode, visibleFills: Fill[], @@ -319,6 +344,16 @@ function buildShapeChildren( visibleStrokeCount: number, ctx: SVGExportContext ): SVGNode[] { + if (hasPathLevelFills(node)) { + return vectorShapeElements( + node, + { fill: fillAttr ?? 'none', ...strokeAttrs }, + strokeAttrs, + ctx, + visibleFills + ) + } + if (visibleFills.length > 1) { const elements: SVGNode[] = [] for (const fill of visibleFills) { @@ -328,7 +363,8 @@ function buildShapeChildren( ...nodeShapeElements( node, ref, - fill === visibleFills[visibleFills.length - 1] ? strokeAttrs : {} + fill === visibleFills[visibleFills.length - 1] ? strokeAttrs : {}, + ctx ) ) } @@ -338,7 +374,7 @@ function buildShapeChildren( const hasFillOrStroke = fillAttr || visibleStrokeCount > 0 if (hasFillOrStroke && !isGroupLike(node)) { - return nodeShapeElements(node, fillAttr, strokeAttrs) + return nodeShapeElements(node, fillAttr, strokeAttrs, ctx) } return [] @@ -358,7 +394,10 @@ function renderNode(node: SceneNode, ctx: SVGExportContext): SVGNode | null { const visibleFills = node.fills.filter((f) => f.visible) const visibleStrokes = node.strokes.filter((s) => s.visible) - const fillAttr = visibleFills.length > 0 ? resolveFill(visibleFills[0], node, ctx) : null + const fillAttr = + visibleFills.length > 0 && !hasPathLevelFills(node) + ? resolveFill(visibleFills[0], node, ctx) + : null const strokeAttrs = buildSVGStrokeAttrs(visibleStrokes, ctx.colorSpace) const children: (SVGNode | null)[] = buildShapeChildren( diff --git a/packages/core/src/io/formats/svg/import.ts b/packages/core/src/io/formats/svg/import.ts index 619ab6b55..a668decf9 100644 --- a/packages/core/src/io/formats/svg/import.ts +++ b/packages/core/src/io/formats/svg/import.ts @@ -1,7 +1,7 @@ import type { SceneGraph, SceneNode } from '@open-pencil/scene-graph' import type { Size } from '@open-pencil/scene-graph/primitives' -import { createVectorFrameChildren } from '#core/vector/vectorize/placement' +import { createFlattenedVectorFrameChildren } from '#core/vector/vectorize/placement' import { svgToVectorPaths, type SVGVectorizeResult } from '#core/vector/vectorize/svg/to-vectors' import { parseSVGSize } from './metadata' @@ -47,7 +47,7 @@ export function createSVGNodesFromImport( }) try { - createVectorFrameChildren(graph, frame.id, data, { + createFlattenedVectorFrameChildren(graph, frame.id, data, { x: frame.x, y: frame.y, width: frame.width, diff --git a/packages/core/src/vector/vectorize/placement.ts b/packages/core/src/vector/vectorize/placement.ts index 651bf05d9..61750c2c7 100644 --- a/packages/core/src/vector/vectorize/placement.ts +++ b/packages/core/src/vector/vectorize/placement.ts @@ -1,9 +1,17 @@ -import type { SceneGraph, SceneNode, VectorNetwork } from '@open-pencil/scene-graph' +import type { + Fill, + GeometryPath, + SceneGraph, + SceneNode, + VectorNetwork +} from '@open-pencil/scene-graph' +import { mergeVectorNetworks } from '@open-pencil/scene-graph' import type { Rect } from '@open-pencil/scene-graph/primitives' import { computeAccurateBounds } from '#core/vector/curve-math' +import { regenerateFillGeometry } from '#core/vector/fill-geometry' -import type { SVGVectorizeResult } from './svg/to-vectors' +import type { SVGVectorizeResult, VectorizedPath } from './svg/to-vectors' export interface VectorFramePlacement { x: number @@ -14,6 +22,13 @@ export interface VectorFramePlacement { offsetY: number } +interface NormalizedVectorGeometry { + network: VectorNetwork + bounds: Rect +} + +type VectorChildPaints = Pick + function shouldTightenToContent( node: Pick, content: Rect @@ -64,10 +79,7 @@ function offsetVectorNetwork( } /** Fit vector geometry to node-local coordinates and a tight width/height (pen-tool pattern). */ -function normalizeVectorToNodeBounds(network: VectorNetwork): { - network: VectorNetwork - bounds: Rect -} | null { +function normalizeVectorToNodeBounds(network: VectorNetwork): NormalizedVectorGeometry | null { if (network.vertices.length === 0) return null const bounds = computeAccurateBounds(network) @@ -85,6 +97,104 @@ function normalizeVectorToNodeBounds(network: VectorNetwork): { } } +function createNormalizedVectorChild( + graph: SceneGraph, + frameId: string, + normalized: NormalizedVectorGeometry, + index: number, + paints: VectorChildPaints +): void { + graph.createNode('VECTOR', frameId, { + name: `path ${index + 1}`, + x: normalized.bounds.x, + y: normalized.bounds.y, + width: normalized.bounds.width, + height: normalized.bounds.height, + vectorNetwork: normalized.network, + ...paints + }) +} + +function createVectorChild( + graph: SceneGraph, + frameId: string, + path: VectorizedPath, + placement: VectorFramePlacement, + index: number +): void { + const inFrame = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY) + const normalized = normalizeVectorToNodeBounds(inFrame) + if (!normalized) return + createNormalizedVectorChild(graph, frameId, normalized, index, { + fillGeometry: [], + fills: path.fills, + strokes: path.strokes + }) +} + +function remapFillsToBounds(fills: Fill[], source: Rect, target: Rect): Fill[] { + return structuredClone(fills).map((fill) => { + const transform = fill.gradientTransform + if ( + !transform || + source.width <= 0 || + source.height <= 0 || + target.width <= 0 || + target.height <= 0 + ) { + return fill + } + const scaleX = source.width / target.width + const scaleY = source.height / target.height + return { + ...fill, + gradientTransform: { + m00: transform.m00 * scaleX, + m01: transform.m01 * scaleX, + m02: (source.x - target.x) / target.width + transform.m02 * scaleX, + m10: transform.m10 * scaleY, + m11: transform.m11 * scaleY, + m12: (source.y - target.y) / target.height + transform.m12 * scaleY + } + } + }) +} + +function createFlattenedVectorChild( + graph: SceneGraph, + frameId: string, + paths: VectorizedPath[], + placement: VectorFramePlacement, + index: number +): void { + const prepared = paths.map((path) => { + const network = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY) + return { path, network, bounds: computeAccurateBounds(network) } + }) + const normalized = normalizeVectorToNodeBounds( + mergeVectorNetworks(prepared.map(({ network }) => network)) + ) + if (!normalized) return + + const placeholders: GeometryPath[] = prepared.flatMap(({ path, network, bounds }) => { + const fills = remapFillsToBounds(path.fills, bounds, normalized.bounds) + return network.regions.map((region) => ({ + windingRule: region.windingRule, + commandsBlob: new Uint8Array(0), + fills: structuredClone(fills) + })) + }) + const fills = prepared[0] + ? remapFillsToBounds(prepared[0].path.fills, prepared[0].bounds, normalized.bounds) + : [] + + createNormalizedVectorChild(graph, frameId, normalized, index, { + fillGeometry: regenerateFillGeometry(normalized.network, placeholders), + fills, + strokes: [] + }) +} + export function createVectorFrameChildren( graph: SceneGraph, frameId: string, @@ -92,19 +202,44 @@ export function createVectorFrameChildren( placement: VectorFramePlacement ): void { for (const [index, path] of vectorized.paths.entries()) { - const inFrame = offsetVectorNetwork(path.vectorNetwork, placement.offsetX, placement.offsetY) - const normalized = normalizeVectorToNodeBounds(inFrame) - if (!normalized) continue - - graph.createNode('VECTOR', frameId, { - name: `path ${index + 1}`, - x: normalized.bounds.x, - y: normalized.bounds.y, - width: normalized.bounds.width, - height: normalized.bounds.height, - vectorNetwork: normalized.network, - fills: path.fills, - strokes: path.strokes - }) + createVectorChild(graph, frameId, path, placement, index) } } + +function isFlattenableVectorPath(path: VectorizedPath): boolean { + return path.fills.length > 0 && path.strokes.length === 0 && path.vectorNetwork.regions.length > 0 +} + +/** Merge adjacent fill-only SVG paths without changing their paint order. */ +export function createFlattenedVectorFrameChildren( + graph: SceneGraph, + frameId: string, + vectorized: SVGVectorizeResult, + placement: VectorFramePlacement +): void { + let run: { path: VectorizedPath; index: number }[] = [] + const flush = () => { + if (run.length > 1) { + createFlattenedVectorChild( + graph, + frameId, + run.map(({ path }) => path), + placement, + run[0].index + ) + } else if (run[0]) { + createVectorChild(graph, frameId, run[0].path, placement, run[0].index) + } + run = [] + } + + for (const [index, path] of vectorized.paths.entries()) { + if (isFlattenableVectorPath(path)) { + run.push({ path, index }) + continue + } + flush() + createVectorChild(graph, frameId, path, placement, index) + } + flush() +} diff --git a/packages/scene-graph/src/index.ts b/packages/scene-graph/src/index.ts index 812b7137d..694fd5d6f 100644 --- a/packages/scene-graph/src/index.ts +++ b/packages/scene-graph/src/index.ts @@ -50,6 +50,7 @@ import type { export { cloneVectorNetwork, + mergeVectorNetworks, normalizeVectorNetwork, transformVectorNetwork, validateVectorNetwork, diff --git a/packages/scene-graph/src/vector-network.ts b/packages/scene-graph/src/vector-network.ts index 47fc43937..96b6f75b4 100644 --- a/packages/scene-graph/src/vector-network.ts +++ b/packages/scene-graph/src/vector-network.ts @@ -4,6 +4,41 @@ import type { Mat3 } from './matrix' import type { Vector } from './primitives' import type { VectorNetwork, VectorSegment } from './types' +/** Concatenate vector networks while preserving independent geometry. */ +export function mergeVectorNetworks(networks: readonly VectorNetwork[]): VectorNetwork { + const vertices: VectorNetwork['vertices'] = [] + const segments: VectorNetwork['segments'] = [] + const regions: VectorNetwork['regions'] = [] + let vertexOffset = 0 + let segmentOffset = 0 + + for (const network of networks) { + for (const vertex of network.vertices) vertices.push({ ...vertex }) + for (const segment of network.segments) { + segments.push({ + ...segment, + start: segment.start + vertexOffset, + end: segment.end + vertexOffset, + tangentStart: { ...segment.tangentStart }, + tangentEnd: { ...segment.tangentEnd } + }) + } + for (const region of network.regions) { + const loops: number[][] = [] + for (const loop of region.loops) { + const shifted: number[] = [] + for (const segmentIndex of loop) shifted.push(segmentIndex + segmentOffset) + loops.push(shifted) + } + regions.push({ windingRule: region.windingRule, loops }) + } + vertexOffset += network.vertices.length + segmentOffset += network.segments.length + } + + return { vertices, segments, regions } +} + /** * Map a VectorNetwork through an affine matrix: vertices as points, * tangents as direction vectors (linear part only, no translation). diff --git a/tests/e2e/canvas/drop-files.spec.ts b/tests/e2e/canvas/drop-files.spec.ts index 2879fe978..11806bddb 100644 --- a/tests/e2e/canvas/drop-files.spec.ts +++ b/tests/e2e/canvas/drop-files.spec.ts @@ -7,7 +7,7 @@ async function dispatchMixedFileDrop() { const bounds = canvas.getBoundingClientRect() const svg = new File( [ - '' + '' ], 'mark.svg' ) @@ -72,6 +72,8 @@ test('mixed SVG and raster drops share placement, selection, and undo', async () selected: 2, undoLabel: 'Place files' }) + await editor.canvas.waitForRender() + expect(await editor.canvas.canvas.screenshot()).toMatchSnapshot('dropped-svg-multicolor.png') await editor.page.evaluate(() => window.openPencil?.getStore?.().undoAction()) await expect diff --git a/tests/e2e/canvas/drop-files.spec.ts-snapshots/dropped-svg-multicolor-openpencil-darwin.png b/tests/e2e/canvas/drop-files.spec.ts-snapshots/dropped-svg-multicolor-openpencil-darwin.png new file mode 100644 index 0000000000000000000000000000000000000000..ec9b2349964e9ca7ae4fd766d3dc8f99a1be7a14 GIT binary patch literal 18083 zcmeHPc{p3?8b4~PS|z&BmSQ^Q(uHEQwbKn#dM#}+_Nb|4TKirTNlj;TTFadlRaL6G z+JcB6A+$v)!Bt`pZS6~t+7e0b$qB|h&z+}t?lb*ko;m)>_nhG+1lG+3Sfas~OPo4n)UQ_6(@H+5An-YB+0K|b)Cy$*AO&RZTjCL4ssp(gj8|$e$ zQY|F?4yk$y)3kl(s0j#=(_T)1Mn-=5Eq{1!%h zohrX|R9IjqzxDZ5U^BlZejFYSwjG287$~q;z!8NX76CYX!dwCKEZ-SmQ2{F`ST6E? z;cqOp3vZ-am9ez^8)kBOX%Ya49b4-cm^ptIKA<`*aHK9~J#>F}!KchYCN8W_bam@$ z{&P->mo#}jbGPydBf5pz@U{JxY&gsL6S5pj&qGq{A>+~`VjXa#EX@xcw6fZEXXv;$ z9auSq^I#S_F*$c}D4H?+?*!=^N^>D0VJs0|ucpC|VWOgZHhWaLvv!oY;rh>kbk_zx z*c32pQ&uF+nigiHR+&rU%$$$(DjzFg?=Bf9#h(8nCKo7oHMCLt<)A~e(^wI->%FUU z?p%vlXw?8O7jBwN%z9pf{n0t;F(Nh}Bq2-qT5PP~ zn*>t7NtZ4rhDdC~x+h3fLzlgbKjuUe-aMBqA(8q04cGQB#2$W4tiCj2+iKsNTh54% zJVDc;(ON?g@r2iuvAxhYg#YnJ^N(vL8O?hE4^y7K0^X16H11VsPoO$Goz80raV$!c zrz+9+i=kLtj@!5jG{uh+70@RDfaie;4p|LZhLS8Ik;H}~mq)-_)AIZOwq$~&B|1*| zsx?pUe;f<$VLQ~vSNPXB1Rnc%SMq>kRXWz)isvoCPR4%2#QA(T2$4tcZdq}b-%(db ziT2=8Hk&Q4yYknQOfbr@T;7B4FmB!dT+#{k$qo-2=HMF?+YmnOhu-Yo-)WI!SFiJ+ zG}L8a{Om5e7_^loW2dDLEfVc`lMWf1xh8;W)3uR8-g zk;e~IzJI_nbq@&&J|~oHh||(V2eaS1j@^hGTj&fa8kb^RaUFFo39TmCw)XcwK>IEv zX(jldUZhJCypfwOj5hV^2lJPIB3V$fc0IEQ+D z8_kB>7s+H&lgFHWWV3Bi{YnOnP%P^MtVDpM^ZF>eq3B!_TPt$J>o$VaiGf6&h;CLz zCufG@NvH3W3837hAY{+dNL~v*J{>bJMdV#(i#CMQm;8Jl?bR>&)=o_zg6?{+r)9r+ zndZVgjaJf2P-=8;t&*&#zfFFb2j6DyGy)7#&pQJx?6@H1*Jr;piQ zE0bU{{Zd2_OVTR5SRWEm$Zp6v!EmRVIpk3d3opE6FVwo)k4MU1e1g88v6EU<-XEkQ z${D|N1sr9r?NydJJ+n}gk5^3|IXq4=Mr)}yDaGn#77Y(~=ElhB`F+`>&mEKo2}?fw<}H6yefRUWZ^Y~ys-68|o=c*)$c>QZZr(9Kd*>0*@4qTl ztjCruTxN95PmE)uf?}0QQKOFrlgK@VK@Y1a$>mgMO+9jY*xN{EL8FXyFPWZSzKF}Z zz*cr;w5TBTSthtpjk@ckg@xD&_6u5gF}BJUa=waVJU5VFGuT~2GM4PA2eIbmsIZD!ohwp|&J5S`qF18|X;2?}(e&Bg!ovkKeuGQ3ya@Ptq5;LgMhL{uG zUelO`@^E1{9!!dO-{0CLJFZ-_c|y3-xFItaJ)x1<)fSv`=rYomG$iL$k)~J1m@!Cl z>ea$9b9)?$}D3^h_XgX6g$B zu;HFlrFRMA9E+9xNS5r}1DvI7G>%)+9|=dyPnJ&)qG_D!!SoLG%rv{N{Lc()5Takc zxfLhFGJRZvGt%$m&~4s1HV`fms7urJGiKaA-(8n(qbBnL5(6b!6CiSA4X|@dKMmUW zbbmFNZtcd8Cpp`|_~@UhqkpeA?D_k2nb(0&T8*t+^l|c!Q~e!kCIu5yn*8chIzcdp z%T9E4-HL0YHIH$eO22DC$W7>E;|j)?*j^RR#K6H*^eplRBZd#h6X71C-?tV61EiV3 zSK6H-jpLmtS1|qzQ8E*E%_^FU#&gRi48k>hq@507gKM>Vh}90vJCk&zv2D_7 z2R)mdL1nWCWn$zw**?rzA&11y^A0wSx*5B1E-g(O-aXpdwQ6Fa){yi1S`-U`-b*3t z7owV*)g#6hn4;*gmm=818}3$F;xa)nBp}$s(;R}7`~aR*HiTv#)Xo}<({DyGH|W%j zm->2p^=6OWwU6>%jGK6;yO`0q`NJFnTRcJ?3wbT%Fs^CJc-JTv&4@xXSuKrAO|Lqh zur@?ti#@6*5ni3z=k#3;{05PWBc)f!#Ior}IHIJCqruG7J;kh0S4I;mZnh_ohE*Sp znfJ~*cRj$IuIpF%s>b$CKpF}k#z1wYMud^@MYK})E**{7BYo4#n*Kwl!9a0jFkPec zfHH+oielGIGVsA6O-fa{mOILJ0O)Z zVy8-Qh!uXEQdS3UxlcbgsVrV$QJ&9-QS|ZFst2Qlc|&2WL0NJ-^_JwhM!kRbb!W}= z#Ya7b=D-LJ*t-D=tsu!KD$R`GHmoJ?r-!6sKmwwIihL54Z97#IV4n4iuki}a0HMCJ z{K8Bgz_32De&A+3nTgGi`gi!{Fp&?&q{Sf;j`tJS1PWqI7yOt1;0PA zZKe2uJiZbg;lTg^wiy6J162F4I{|Ri001~R0su@X008qQtTlMT36=-2JOE(;%LCy5 zmj|t-js~l>0GM20a^VG5IQN9fg@*&UECG`X2m?6xgmX_`IDq8=EDv}%faL)!4|q6$ z<-w { ).toBeGreaterThan(0) }) - test('imports multiple shapes', async () => { + test('flattens compatible filled shapes into one vector', async () => { const result = (await importSvg.execute(figma, { svg: ` - - - + + + ` })) as { id: string } const children = graph.getChildren(result.id) - expect(children.length).toBe(3) - expect(children.every((c) => c.type === 'VECTOR')).toBe(true) + expect(children).toHaveLength(1) + const vector = expectDefined(children[0]) + expect(vector.type).toBe('VECTOR') + expect(expectDefined(vector.vectorNetwork).regions).toHaveLength(3) + expect(vector.fillGeometry).toHaveLength(3) + expect(expectDefined(vector.fillGeometry[0]?.fills?.[0]).color.r).toBeCloseTo(1) + expect(expectDefined(vector.fillGeometry[1]?.fills?.[0]).color.g).toBeCloseTo(1) + expect(expectDefined(vector.fillGeometry[2]?.fills?.[0]).color.b).toBeCloseTo(1) + expect(vector.fillGeometry.every(({ commandsBlob }) => commandsBlob.length > 0)).toBe(true) + + const page = expectDefined(graph.getPages()[0]) + const exported = expectDefined(renderNodesToSVG(graph, page.id, [result.id])) + expect(exported.match(/fill="#FF0000"/g)).toHaveLength(1) + expect(exported.match(/fill="#00FF00"/g)).toHaveLength(1) + expect(exported.match(/fill="#0000FF"/g)).toHaveLength(1) + }) + + test('flattens a real multi-path provider SVG', async () => { + const svg = readFileSync( + join(process.cwd(), 'tests/fixtures/vectorize/euro_shield.recraft.svg'), + 'utf8' + ) + const result = (await importSvg.execute(figma, { svg })) as { id: string } + + const vector = expectDefined(graph.getChildren(result.id)[0]) + const network = expectDefined(vector.vectorNetwork) + expect(graph.getChildren(result.id)).toHaveLength(1) + expect(network.regions.length).toBeGreaterThan(10) + expect(vector.fillGeometry).toHaveLength(network.regions.length) + }) + + test('preserves paint order around stroked paths', async () => { + const result = (await importSvg.execute(figma, { + svg: ` + + + + + + ` + })) as { id: string } + + const children = graph.getChildren(result.id) + expect(children).toHaveLength(3) + expect(expectDefined(children[0]).fillGeometry).toHaveLength(2) + expect(expectDefined(children[1]).strokes).toHaveLength(1) + expect(expectDefined(children[2]).fillGeometry).toHaveLength(2) + }) + + test('remaps path gradients into flattened vector bounds', async () => { + const result = (await importSvg.execute(figma, { + svg: `` + })) as { id: string } + + const vector = expectDefined(graph.getChildren(result.id)[0]) + const left = expectDefined(vector.fillGeometry[0]?.fills?.[0]?.gradientTransform) + const right = expectDefined(vector.fillGeometry[1]?.fills?.[0]?.gradientTransform) + expect(left.m00).toBeCloseTo(0.5) + expect(left.m02).toBeCloseTo(0) + expect(right.m00).toBeCloseTo(0.5) + expect(right.m02).toBeCloseTo(0.5) + + const page = expectDefined(graph.getPages()[0]) + const exported = expectDefined(renderNodesToSVG(graph, page.id, [result.id])) + expect(exported.match(/