From 4bb99a71fa293bce8ae6ab441f92c4b386c8cddb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 16:38:11 +0000 Subject: [PATCH] fix(refig): REST API JSON without geometry=paths causes 0x0 raster surface panic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of https://github.com/gridaco/grida/issues/585: The Figma REST API GET /v1/files/:key response omits `size` and `relativeTransform` from every node when `geometry=paths` is not passed as a query param. The io-figma `positioning_trait` helper fell back to 0 for both width and height, producing 0x0 node dimensions. This caused `Backend::new_from_raster(0, 0)` to call `skia_safe::surfaces::raster_n32_premul((0, 0))` which returns `None` and the `.expect()` panicked with 'Failed to create raster surface'. Fixes: 1. packages/grida-canvas-io-figma/lib.ts — positioning_trait now accepts an optional parent node and falls back to absoluteBoundingBox for dimensions when size is absent, and computes relative insets from child/parent absolute bounding boxes when relativeTransform is absent. All node type handlers (FRAME, RECTANGLE, ELLIPSE, GROUP, SECTION, BOOLEAN_OPERATION, LINE, VECTOR, X_VECTOR, X_STAR, X_REGULAR_POLYGON) pass parent to positioning_trait. TEXT node likewise uses absoluteBoundingBox as fallback for size and position. 2. crates/grida-canvas/src/export/{export_as_image,export_as_svg,export_as_pdf}.rs — Added defensive guards that return None before calling Backend::new_from_raster when pixel dimensions are <= 0, preventing the panic in any future edge cases. Co-authored-by: Universe --- .../src/export/export_as_image.rs | 15 +- .../grida-canvas/src/export/export_as_pdf.rs | 5 + .../grida-canvas/src/export/export_as_svg.rs | 9 +- .../iofigma.rest-api.no-geometry.test.ts | 403 ++++++++++++++++++ packages/grida-canvas-io-figma/lib.ts | 124 ++++-- .../__tests__/refig.test.ts | 107 +++++ 6 files changed, 633 insertions(+), 30 deletions(-) create mode 100644 packages/grida-canvas-io-figma/__tests__/iofigma.rest-api.no-geometry.test.ts diff --git a/crates/grida-canvas/src/export/export_as_image.rs b/crates/grida-canvas/src/export/export_as_image.rs index 947381b3c7..bda12d90a5 100644 --- a/crates/grida-canvas/src/export/export_as_image.rs +++ b/crates/grida-canvas/src/export/export_as_image.rs @@ -31,6 +31,13 @@ pub fn export_node_as_image( rect: Rectangle, format: ExportAsImage, ) -> Option { + // Guard: Skia cannot create a raster surface with zero or negative dimensions. + let pixel_w = size.width as i32; + let pixel_h = size.height as i32; + if pixel_w <= 0 || pixel_h <= 0 { + return None; + } + let skfmt: EncodedImageFormat = format.clone().into(); // Create camera with original bounds to determine world-space view @@ -39,7 +46,11 @@ pub fn export_node_as_image( // Scale the camera size to target resolution and adjust zoom to maintain same world-space view // When we increase the viewport size and zoom IN proportionally, we see the same world-space rect // but at higher resolution (scale = 2 means 2x zoom, 2x pixels, same world-space view) - let scale = size.width / rect.width; + let scale = if rect.width > 0.0 { + size.width / rect.width + } else { + 1.0 + }; camera.set_size(Size { width: size.width, height: size.height, @@ -48,7 +59,7 @@ pub fn export_node_as_image( let store = fonts.store(); let mut r = Renderer::new_with_store( - Backend::new_from_raster(size.width as i32, size.height as i32), + Backend::new_from_raster(pixel_w, pixel_h), None, camera, store, diff --git a/crates/grida-canvas/src/export/export_as_pdf.rs b/crates/grida-canvas/src/export/export_as_pdf.rs index 5b3ff0f22f..3f5a17ddda 100644 --- a/crates/grida-canvas/src/export/export_as_pdf.rs +++ b/crates/grida-canvas/src/export/export_as_pdf.rs @@ -29,6 +29,11 @@ pub fn export_node_as_pdf( let width = rect.width; let height = rect.height; + // Guard: Skia cannot create a raster surface with zero or negative dimensions. + if width as i32 <= 0 || height as i32 <= 0 { + return None; + } + // Begin a new page let mut page = doc.begin_page(SkSize::new(width, height), None); let canvas = page.canvas(); diff --git a/crates/grida-canvas/src/export/export_as_svg.rs b/crates/grida-canvas/src/export/export_as_svg.rs index fd5a61f98e..ef7536d917 100644 --- a/crates/grida-canvas/src/export/export_as_svg.rs +++ b/crates/grida-canvas/src/export/export_as_svg.rs @@ -21,6 +21,13 @@ pub fn export_node_as_svg( let width = rect.width; let height = rect.height; + // Guard: Skia cannot create a raster surface with zero or negative dimensions. + let pixel_w = width as i32; + let pixel_h = height as i32; + if pixel_w <= 0 || pixel_h <= 0 { + return None; + } + // Create SVG canvas let bounds = SkRect::from_wh(width, height); let canvas = svg::Canvas::new(bounds, None); @@ -31,7 +38,7 @@ pub fn export_node_as_svg( // Temporary renderer using raster backend sharing the ByteStore let store = fonts.store(); let mut renderer = Renderer::new_with_store( - Backend::new_from_raster(width as i32, height as i32), + Backend::new_from_raster(pixel_w, pixel_h), None, camera, store, diff --git a/packages/grida-canvas-io-figma/__tests__/iofigma.rest-api.no-geometry.test.ts b/packages/grida-canvas-io-figma/__tests__/iofigma.rest-api.no-geometry.test.ts new file mode 100644 index 0000000000..8990b43921 --- /dev/null +++ b/packages/grida-canvas-io-figma/__tests__/iofigma.rest-api.no-geometry.test.ts @@ -0,0 +1,403 @@ +/** + * Tests for Figma REST API JSON documents fetched WITHOUT `geometry=paths`. + * + * In this mode Figma omits `size` and `relativeTransform` from every node. + * Only `absoluteBoundingBox` (and `absoluteRenderBounds`) is present. + * + * Regression test for: https://github.com/gridaco/grida/issues/585 + * "Node: panic 'Failed to create raster surface'" — caused because + * `positioning_trait` fell back to width=0/height=0 when `size` was absent, + * resulting in `Backend::new_from_raster(0, 0)` panicking in Rust. + */ + +import { iofigma } from "../lib"; +import type * as figrest from "@figma/rest-api-spec"; +import type grida from "@grida/schema"; + +const context: iofigma.restful.factory.FactoryContext = { + gradient_id_generator: () => "grad-1", + prefer_path_for_geometry: true, +}; + +/** + * Build a minimal Figma REST FRAME node that omits `size` and + * `relativeTransform` — exactly what the Figma REST API returns when the + * request does NOT include `geometry=paths`. + */ +function makeFrameNodeWithoutGeometry( + overrides: Partial = {} +): figrest.FrameNode { + const base: figrest.FrameNode = { + id: "1:1", + name: "Frame", + type: "FRAME", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + clipsContent: true, + absoluteBoundingBox: { x: 10, y: 20, width: 400, height: 300 }, + absoluteRenderBounds: { x: 10, y: 20, width: 400, height: 300 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + background: [], + backgroundColor: { r: 0, g: 0, b: 0, a: 0 }, + children: [], + // Intentionally omit: size, relativeTransform + } as figrest.FrameNode; + return { ...base, ...overrides }; +} + +describe("iofigma.restful.factory – REST API without geometry=paths", () => { + describe("positioning_trait fallback to absoluteBoundingBox", () => { + it("root FRAME node gets correct non-zero dimensions from absoluteBoundingBox", () => { + const frameNode = makeFrameNodeWithoutGeometry(); + + const { document: doc } = iofigma.restful.factory.document( + frameNode, + {}, + context + ); + + const frameGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.ContainerNode => + n.type === "container" && n.name === "Frame" + ); + expect(frameGrida).toBeDefined(); + // Must use absoluteBoundingBox dimensions — NOT fall back to 0 + expect(frameGrida!.layout_target_width).toBe(400); + expect(frameGrida!.layout_target_height).toBe(300); + }); + + it("root node insets are 0 when no parent (absolute coords become scene origin)", () => { + const frameNode = makeFrameNodeWithoutGeometry(); + + const { document: doc } = iofigma.restful.factory.document( + frameNode, + {}, + context + ); + + const frameGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.ContainerNode => n.type === "container" + ); + expect(frameGrida).toBeDefined(); + // Root node: inset = absBox.x - absBox.x = 0 + expect(frameGrida!.layout_inset_left).toBe(0); + expect(frameGrida!.layout_inset_top).toBe(0); + }); + + it("child RECTANGLE gets correct dimensions and relative insets from absoluteBoundingBox", () => { + const childRect: figrest.RectangleNode = { + id: "1:2", + name: "Child Rect", + type: "RECTANGLE", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { x: 30, y: 50, width: 100, height: 80 }, + absoluteRenderBounds: { x: 30, y: 50, width: 100, height: 80 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + cornerRadius: 0, + exportSettings: [], + interactions: [], + // Intentionally omit: size, relativeTransform + } as figrest.RectangleNode; + + const frameNode = makeFrameNodeWithoutGeometry({ + children: [childRect as unknown as figrest.SubcanvasNode], + }); + + const { document: doc } = iofigma.restful.factory.document( + frameNode, + {}, + context + ); + + const rectGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.RectangleNode => + n.type === "rectangle" && n.name === "Child Rect" + ); + expect(rectGrida).toBeDefined(); + + // Dimensions from absoluteBoundingBox + expect(rectGrida!.layout_target_width).toBe(100); + expect(rectGrida!.layout_target_height).toBe(80); + + // Relative insets: child(30,50) - parent(10,20) = (20, 30) + expect(rectGrida!.layout_inset_left).toBe(20); + expect(rectGrida!.layout_inset_top).toBe(30); + }); + + it("child ELLIPSE gets correct dimensions and relative insets", () => { + const childEllipse: figrest.EllipseNode = { + id: "1:3", + name: "Circle", + type: "ELLIPSE", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { x: 60, y: 70, width: 50, height: 50 }, + absoluteRenderBounds: { x: 60, y: 70, width: 50, height: 50 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + arcData: { startingAngle: 0, endingAngle: 6.28, innerRadius: 0 }, + // Intentionally omit: size, relativeTransform + } as figrest.EllipseNode; + + const frameNode = makeFrameNodeWithoutGeometry({ + children: [childEllipse as unknown as figrest.SubcanvasNode], + }); + + const { document: doc } = iofigma.restful.factory.document( + frameNode, + {}, + context + ); + + const ellipseGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.EllipseNode => + n.type === "ellipse" && n.name === "Circle" + ); + expect(ellipseGrida).toBeDefined(); + expect(ellipseGrida!.layout_target_width).toBe(50); + expect(ellipseGrida!.layout_target_height).toBe(50); + // Relative insets: child(60,70) - parent(10,20) = (50, 50) + expect(ellipseGrida!.layout_inset_left).toBe(50); + expect(ellipseGrida!.layout_inset_top).toBe(50); + }); + + it("nested child FRAME gets dimensions and relative insets computed from absolute positions", () => { + const grandchildRect: figrest.RectangleNode = { + id: "1:4", + name: "GrandchildRect", + type: "RECTANGLE", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { x: 50, y: 60, width: 80, height: 60 }, + absoluteRenderBounds: { x: 50, y: 60, width: 80, height: 60 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + cornerRadius: 0, + exportSettings: [], + interactions: [], + } as figrest.RectangleNode; + + const childFrame: figrest.FrameNode = { + id: "1:5", + name: "ChildFrame", + type: "FRAME", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + clipsContent: true, + absoluteBoundingBox: { x: 40, y: 50, width: 200, height: 150 }, + absoluteRenderBounds: { x: 40, y: 50, width: 200, height: 150 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + background: [], + backgroundColor: { r: 0, g: 0, b: 0, a: 0 }, + children: [grandchildRect as unknown as figrest.SubcanvasNode], + } as figrest.FrameNode; + + const rootFrame = makeFrameNodeWithoutGeometry({ + children: [childFrame as unknown as figrest.SubcanvasNode], + }); + + const { document: doc } = iofigma.restful.factory.document( + rootFrame, + {}, + context + ); + + const childGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.ContainerNode => + n.type === "container" && n.name === "ChildFrame" + ); + expect(childGrida).toBeDefined(); + // ChildFrame: size from absoluteBoundingBox + expect(childGrida!.layout_target_width).toBe(200); + expect(childGrida!.layout_target_height).toBe(150); + // Relative to root(10,20): (40-10, 50-20) = (30, 30) + expect(childGrida!.layout_inset_left).toBe(30); + expect(childGrida!.layout_inset_top).toBe(30); + + const grandchildGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.RectangleNode => + n.type === "rectangle" && n.name === "GrandchildRect" + ); + expect(grandchildGrida).toBeDefined(); + expect(grandchildGrida!.layout_target_width).toBe(80); + expect(grandchildGrida!.layout_target_height).toBe(60); + // Relative to childFrame(40,50): (50-40, 60-50) = (10, 10) + expect(grandchildGrida!.layout_inset_left).toBe(10); + expect(grandchildGrida!.layout_inset_top).toBe(10); + }); + }); + + describe("TEXT node without geometry=paths", () => { + it("TEXT node uses absoluteBoundingBox for dimensions when size is absent", () => { + const textNode: figrest.TextNode = { + id: "4:1", + name: "Label", + type: "TEXT", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { x: 20, y: 30, width: 128, height: 16 }, + absoluteRenderBounds: { x: 20, y: 30, width: 120, height: 12 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [{ type: "SOLID", color: { r: 0, g: 0, b: 0, a: 1 }, blendMode: "NORMAL", visible: true }], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + characters: "Hello World", + style: { + fontFamily: "Inter", + fontPostScriptName: "Inter-Regular", + fontWeight: 400, + fontSize: 14, + textAlignHorizontal: "LEFT", + textAlignVertical: "TOP", + letterSpacing: 0, + lineHeightPx: 16, + lineHeightPercent: 100, + lineHeightPercentFontSize: 114, + lineHeightUnit: "PIXELS", + italic: false, + textDecoration: "NONE", + textAutoResize: "HEIGHT", + paragraphIndent: 0, + paragraphSpacing: 0, + hangingList: false, + hangingPunctuation: false, + listSpacing: 0, + fontVariations: [], + fills: [], + opentypeFlags: {}, + }, + // Intentionally omit: size, relativeTransform + } as figrest.TextNode; + + const frameNode = makeFrameNodeWithoutGeometry({ + children: [textNode as unknown as figrest.SubcanvasNode], + }); + + // Should NOT throw when size / relativeTransform are absent + expect(() => { + iofigma.restful.factory.document(frameNode, {}, context); + }).not.toThrow(); + + const { document: doc } = iofigma.restful.factory.document( + frameNode, + {}, + context + ); + + const textGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.TextSpanNode => + n.type === "tspan" && n.name === "Label" + ); + expect(textGrida).toBeDefined(); + // Width from absoluteBoundingBox: 128; height depends on textAutoResize=HEIGHT → "auto" + expect(textGrida!.layout_target_width).toBe(128); + expect(textGrida!.layout_target_height).toBe("auto"); + }); + }); + + describe("issue-585: full REST API JSON document (no geometry=paths)", () => { + it("converts the reported figma-file.json structure without zero-sized nodes", () => { + // Reproduces the exact node structure from the uploaded figma-file.json + const rootFrame: figrest.FrameNode = { + id: "1:97", + name: "ws-intense-next-advertising-agency/", + type: "FRAME", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + clipsContent: true, + absoluteBoundingBox: { x: 38, y: -245, width: 420, height: 490 }, + absoluteRenderBounds: { x: 38, y: -245, width: 420, height: 490 }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + background: [], + backgroundColor: { r: 0, g: 0, b: 0, a: 0 }, + children: [ + { + id: "1:113", + name: "border", + type: "RECTANGLE", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { x: 58, y: -217, width: 380, height: 462 }, + // absoluteRenderBounds is null for node 1:113 in the reported file + absoluteRenderBounds: null, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + cornerRadius: 0, + exportSettings: [], + interactions: [], + } as unknown as figrest.SubcanvasNode, + ], + // Intentionally omit: size, relativeTransform + } as figrest.FrameNode; + + const { document: doc } = iofigma.restful.factory.document( + rootFrame, + {}, + context + ); + + // Root frame must have positive dimensions (not 0x0 which would panic in Rust) + const rootGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.ContainerNode => + n.type === "container" && n.name === "ws-intense-next-advertising-agency/" + ); + expect(rootGrida).toBeDefined(); + expect(rootGrida!.layout_target_width).toBeGreaterThan(0); + expect(rootGrida!.layout_target_height).toBeGreaterThan(0); + + // Child with null absoluteRenderBounds but valid absoluteBoundingBox + const childGrida = Object.values(doc.nodes).find( + (n): n is grida.program.nodes.RectangleNode => + n.type === "rectangle" && n.name === "border" + ); + expect(childGrida).toBeDefined(); + expect(childGrida!.layout_target_width).toBeGreaterThan(0); + expect(childGrida!.layout_target_height).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/grida-canvas-io-figma/lib.ts b/packages/grida-canvas-io-figma/lib.ts index d7df13fa6e..cc2a84fec7 100644 --- a/packages/grida-canvas-io-figma/lib.ts +++ b/packages/grida-canvas-io-figma/lib.ts @@ -596,6 +596,11 @@ export namespace iofigma { /** * Positioning properties - IPositioning + * + * When `size` and `relativeTransform` are absent (e.g. Figma REST API + * responses fetched without the `geometry=paths` query parameter), falls + * back to `absoluteBoundingBox` for dimensions and computes insets from + * absolute positions relative to the parent node's absolute bounding box. */ function positioning_trait( node: @@ -603,7 +608,9 @@ export namespace iofigma { | { relativeTransform?: any; size?: any; - } + absoluteBoundingBox?: any; + }, + parent?: { absoluteBoundingBox?: { x: number; y: number } | null } | null ): Pick< grida.program.nodes.ContainerNode, | "layout_positioning" @@ -613,8 +620,15 @@ export namespace iofigma { | "layout_target_height" | "layout_target_aspect_ratio" > { - const szx = node.size?.x ?? 0; - const szy = node.size?.y ?? 0; + // Fallback: REST API without geometry=paths omits `size` and + // `relativeTransform`; use absoluteBoundingBox as the source of truth. + const absBox = (node as any).absoluteBoundingBox as + | { x: number; y: number; width: number; height: number } + | null + | undefined; + + const szx = node.size?.x ?? absBox?.width ?? 0; + const szy = node.size?.y ?? absBox?.height ?? 0; // Align spec: use REST `preserveRatio` as the canonical flag. const constrained = @@ -627,10 +641,26 @@ export namespace iofigma { ? cmath.aspectRatio(tar?.x ?? szx, tar?.y ?? szy, 1000) : undefined; + let inset_left: number; + let inset_top: number; + if (node.relativeTransform != null) { + inset_left = node.relativeTransform[0][2]; + inset_top = node.relativeTransform[1][2]; + } else if (absBox != null) { + // Compute relative position from absolute bounding boxes. + // When there is no parent, the node sits at origin in the exported scene. + const parentAbsBox = parent?.absoluteBoundingBox; + inset_left = absBox.x - (parentAbsBox?.x ?? absBox.x); + inset_top = absBox.y - (parentAbsBox?.y ?? absBox.y); + } else { + inset_left = 0; + inset_top = 0; + } + return { layout_positioning: "absolute" as const, - layout_inset_left: node.relativeTransform?.[0][2] ?? 0, - layout_inset_top: node.relativeTransform?.[1][2] ?? 0, + layout_inset_left: inset_left, + layout_inset_top: inset_top, layout_target_width: szx, layout_target_height: szy, layout_target_aspect_ratio, @@ -1384,7 +1414,7 @@ export namespace iofigma { opacity: 1, blendMode: "PASS_THROUGH", }), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...style_trait({}), @@ -1404,7 +1434,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...style_trait({ @@ -1426,7 +1456,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), type: "group", } satisfies grida.program.nodes.GroupNode; } @@ -1435,16 +1465,38 @@ export namespace iofigma { const figma_constraints_horizontal = node.constraints?.horizontal; const figma_constraints_vertical = node.constraints?.vertical; - const fixedwidth = node.size!.x; - const fixedheight = node.size!.y; + // Fallback for REST API without geometry=paths: use absoluteBoundingBox + const textAbsBox = node.absoluteBoundingBox; + const parentAbsBox = parent?.absoluteBoundingBox; + + const fixedwidth = + node.size?.x ?? textAbsBox?.width ?? 0; + const fixedheight = + node.size?.y ?? textAbsBox?.height ?? 0; + + let fixedleft: number; + let fixedtop: number; + if (node.relativeTransform != null) { + fixedleft = node.relativeTransform[0][2]; + fixedtop = node.relativeTransform[1][2]; + } else if (textAbsBox != null) { + fixedleft = textAbsBox.x - (parentAbsBox?.x ?? textAbsBox.x); + fixedtop = textAbsBox.y - (parentAbsBox?.y ?? textAbsBox.y); + } else { + fixedleft = 0; + fixedtop = 0; + } - const fixedleft = node.relativeTransform![0][2]; - const fixedtop = node.relativeTransform![1][2]; - const fixedright = parent?.size - ? parent.size.x - fixedleft - fixedwidth + // Compute right/bottom insets using parent size (prefer size, fall back to absBox) + const parentWidth = + parent?.size?.x ?? parentAbsBox?.width; + const parentHeight = + parent?.size?.y ?? parentAbsBox?.height; + const fixedright = parentWidth != null + ? parentWidth - fixedleft - fixedwidth : undefined; - const fixedbottom = parent?.size - ? parent.size.y - fixedtop - fixedheight + const fixedbottom = parentHeight != null + ? parentHeight - fixedtop - fixedheight : undefined; const constraints = { @@ -1511,7 +1563,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...corner_radius_trait(node), @@ -1523,7 +1575,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...arc_data_trait(node), @@ -1535,7 +1587,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...effects_trait(node.effects), @@ -1544,6 +1596,24 @@ export namespace iofigma { } satisfies grida.program.nodes.BooleanPathOperationNode; } case "LINE": { + // Fallback for REST API without geometry=paths: use absoluteBoundingBox + const lineAbsBox = (node as any).absoluteBoundingBox as + | { x: number; y: number; width: number; height: number } + | null + | undefined; + const lineParentAbsBox = parent?.absoluteBoundingBox; + let lineLeft: number; + let lineTop: number; + if (node.relativeTransform != null) { + lineLeft = node.relativeTransform[0][2]; + lineTop = node.relativeTransform[1][2]; + } else if (lineAbsBox != null) { + lineLeft = lineAbsBox.x - (lineParentAbsBox?.x ?? lineAbsBox.x); + lineTop = lineAbsBox.y - (lineParentAbsBox?.y ?? lineAbsBox.y); + } else { + lineLeft = 0; + lineTop = 0; + } return { id: gridaId, ...base_node_trait(node), @@ -1551,9 +1621,9 @@ export namespace iofigma { ...effects_trait(node.effects), type: "line", layout_positioning: "absolute", - layout_inset_left: node.relativeTransform![0][2], - layout_inset_top: node.relativeTransform![1][2], - layout_target_width: node.size!.x, + layout_inset_left: lineLeft, + layout_inset_top: lineTop, + layout_target_width: node.size?.x ?? lineAbsBox?.width ?? 0, layout_target_height: 0, } satisfies grida.program.nodes.LineNode; } @@ -1588,7 +1658,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...corner_radius_trait(node), @@ -1606,7 +1676,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), type: "group", } satisfies grida.program.nodes.GroupNode; } @@ -1627,7 +1697,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...corner_radius_trait(node), @@ -1640,7 +1710,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...effects_trait(node.effects), @@ -1653,7 +1723,7 @@ export namespace iofigma { return { id: gridaId, ...base_node_trait(node), - ...positioning_trait(node), + ...positioning_trait(node, parent), ...fills_trait(node.fills, context, imageRefsUsed), ...stroke_trait(node, context, imageRefsUsed), ...effects_trait(node.effects), diff --git a/packages/grida-canvas-sdk-render-figma/__tests__/refig.test.ts b/packages/grida-canvas-sdk-render-figma/__tests__/refig.test.ts index d8294be77d..03d9bd3185 100644 --- a/packages/grida-canvas-sdk-render-figma/__tests__/refig.test.ts +++ b/packages/grida-canvas-sdk-render-figma/__tests__/refig.test.ts @@ -525,4 +525,111 @@ describe("@grida/refig (real render)", () => { renderer.dispose(); } }, 60_000); + + it( + "renders REST API doc without size/relativeTransform (issue-585 regression)", + async () => { + // Reproduces the exact node structure from the uploaded figma-file.json in + // https://github.com/gridaco/grida/issues/585. + // The Figma REST API GET /v1/files/:key response omits `size` and + // `relativeTransform` on every node. Previously, positioning_trait fell + // back to width=0/height=0 causing Backend::new_from_raster(0,0) to panic. + const figmaFileJson = { + document: { + id: "0:0", + type: "DOCUMENT", + name: "Test", + children: [ + { + id: "0:1", + type: "CANVAS", + name: "Page 1", + children: [ + { + id: "1:97", + name: "ws-intense-next-advertising-agency/", + type: "FRAME", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + clipsContent: true, + absoluteBoundingBox: { + x: 38, + y: -245, + width: 420, + height: 490, + }, + absoluteRenderBounds: { + x: 38, + y: -245, + width: 420, + height: 490, + }, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + exportSettings: [], + interactions: [], + background: [], + backgroundColor: { r: 0, g: 0, b: 0, a: 0 }, + // Intentionally absent: size, relativeTransform + children: [ + { + id: "1:113", + name: "border", + type: "RECTANGLE", + scrollBehavior: "SCROLLS", + blendMode: "PASS_THROUGH", + absoluteBoundingBox: { + x: 58, + y: -217, + width: 380, + height: 462, + }, + // null absoluteRenderBounds — exactly as in the reported file + absoluteRenderBounds: null, + constraints: { vertical: "TOP", horizontal: "LEFT" }, + fills: [ + { + type: "SOLID", + color: { r: 1, g: 1, b: 1, a: 1 }, + blendMode: "NORMAL", + visible: true, + }, + ], + strokes: [], + strokeWeight: 1, + strokeAlign: "INSIDE", + effects: [], + cornerRadius: 0, + exportSettings: [], + interactions: [], + }, + ], + }, + ], + }, + ], + }, + }; + + const renderer = new FigmaRenderer(figmaFileJson as any, { + loadFigmaDefaultFonts: false, + }); + try { + // This must not throw/panic — previously panicked with + // "Failed to create raster surface" because node bounds were 0x0 + const result = await renderer.render("1:97", { format: "png" }); + expectPng(result.data); + + const outPath = join(TEST_OUTPUT_DIR, "issue-585-no-geometry.png"); + writeFileSync(outPath, Buffer.from(result.data)); + } finally { + renderer.dispose(); + } + }, + 30_000 + ); });