diff --git a/src/extractors/built-in.ts b/src/extractors/built-in.ts index 2987f220..39619b30 100644 --- a/src/extractors/built-in.ts +++ b/src/extractors/built-in.ts @@ -207,11 +207,13 @@ export const layoutOnly = [layoutExtractor]; /** * Node types that can be exported as SVG images. - * When a FRAME, GROUP, or INSTANCE contains only these types, we can collapse it to IMAGE-SVG. - * Note: FRAME/GROUP/INSTANCE are NOT included here—they're only eligible if collapsed to IMAGE-SVG. + * When a FRAME, GROUP, INSTANCE, or BOOLEAN_OPERATION contains only these types, we can collapse + * it to IMAGE-SVG. BOOLEAN_OPERATION is included because it's both a collapsible container AND + * SVG-eligible as a child (boolean ops always produce vector output). */ export const SVG_ELIGIBLE_TYPES = new Set([ "IMAGE-SVG", // VECTOR nodes are converted to IMAGE-SVG, or containers that were collapsed + "BOOLEAN_OPERATION", "STAR", "LINE", "ELLIPSE", @@ -222,7 +224,7 @@ export const SVG_ELIGIBLE_TYPES = new Set([ /** * afterChildren callback that collapses SVG-heavy containers to IMAGE-SVG. * - * If a FRAME, GROUP, or INSTANCE contains only SVG-eligible children, the parent + * If a FRAME, GROUP, INSTANCE, or BOOLEAN_OPERATION contains only SVG-eligible children, the parent * is marked as IMAGE-SVG and children are omitted, reducing payload size. * * @param node - Original Figma node @@ -238,7 +240,10 @@ export function collapseSvgContainers( const allChildrenAreSvgEligible = children.every((child) => SVG_ELIGIBLE_TYPES.has(child.type)); if ( - (node.type === "FRAME" || node.type === "GROUP" || node.type === "INSTANCE") && + (node.type === "FRAME" || + node.type === "GROUP" || + node.type === "INSTANCE" || + node.type === "BOOLEAN_OPERATION") && allChildrenAreSvgEligible && !hasImageFillInChildren(node) ) { diff --git a/src/tests/tree-walker.test.ts b/src/tests/tree-walker.test.ts index dde978d4..d5b66076 100644 --- a/src/tests/tree-walker.test.ts +++ b/src/tests/tree-walker.test.ts @@ -105,6 +105,59 @@ describe("extractFromDesign", () => { }); }); +describe("collapseSvgContainers", () => { + it("collapses BOOLEAN_OPERATION nodes to IMAGE-SVG", async () => { + const booleanOpNode = makeNode({ + id: "5:1", + name: "Combined Shape", + type: "BOOLEAN_OPERATION", + booleanOperation: "UNION", + children: [ + makeNode({ id: "5:2", name: "Circle", type: "ELLIPSE" }), + makeNode({ id: "5:3", name: "Square", type: "RECTANGLE" }), + ], + }); + + const { nodes } = await extractFromDesign([booleanOpNode], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); + + it("collapses a frame containing a BOOLEAN_OPERATION to IMAGE-SVG", async () => { + const frameWithBoolOp = makeNode({ + id: "6:1", + name: "Icon Frame", + type: "FRAME", + children: [ + makeNode({ + id: "6:2", + name: "Union", + type: "BOOLEAN_OPERATION", + booleanOperation: "UNION", + children: [ + makeNode({ id: "6:3", name: "A", type: "RECTANGLE" }), + makeNode({ id: "6:4", name: "B", type: "ELLIPSE" }), + ], + }), + ], + }); + + const { nodes } = await extractFromDesign([frameWithBoolOp], allExtractors, { + afterChildren: collapseSvgContainers, + }); + + // The BOOLEAN_OPERATION collapses to IMAGE-SVG first (bottom-up), + // then the FRAME sees all children are SVG-eligible and collapses too. + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe("IMAGE-SVG"); + expect(nodes[0].children).toBeUndefined(); + }); +}); + describe("simplifyRawFigmaObject", () => { it("produces a complete SimplifiedDesign from a mock API response", async () => { const mockResponse = {