@grida/refig 0.0.1 → 0.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # `@grida/refig`
2
2
 
3
- > **re**nder **fig**ma — headless Figma renderer (Node.js + browser) in the spirit of [`resvg-js`](https://github.com/nicolo-ribaudo/resvg-js)
3
+ > **re**nder **fig**ma — headless Figma renderer (Node.js + browser) in the spirit of [`resvg`](https://github.com/linebender/resvg)
4
4
 
5
5
  Render Figma documents to **PNG, JPEG, WebP, PDF, and SVG** in **Node.js (no browser required)** or directly in the **browser**.
6
6
 
@@ -194,8 +194,9 @@ pnpm add -g @grida/refig
194
194
  Or run without installing:
195
195
 
196
196
  ```sh
197
- npx @grida/refig <input> --node <node-id> --out <path>
198
- pnpm dlx @grida/refig <input> --node <node-id> --out <path>
197
+ # Instant usage (writes to OS temp dir; output path printed)
198
+ npx @grida/refig <input> --node <node-id> --format png
199
+ pnpm dlx @grida/refig <input> --export-all
199
200
  ```
200
201
 
201
202
  ### Usage
@@ -211,44 +212,50 @@ Using a directory avoids passing the document and images separately.
211
212
 
212
213
  ```sh
213
214
  # Single node (default)
214
- refig <input> --node <node-id> --out <path> [options]
215
+ # - Without --out: writes to OS temp dir (requires --format)
216
+ # - With --out: format inferred from file extension unless --format is provided
217
+ refig <input> --node <node-id> --format <fmt> [options]
218
+ refig <input> --node <node-id> --out <path> [--format <fmt>] [options]
215
219
 
216
220
  # With images directory (REST JSON only; IMAGE fills rendered from local files)
217
- refig <input> --images <dir> --node <node-id> --out <path>
221
+ refig <input> --images <dir> --node <node-id> --format <fmt> [options]
222
+ refig <input> --images <dir> --node <node-id> --out <path> [--format <fmt>] [options]
218
223
 
219
224
  # Directory input: document.json + images/ under one folder
220
- refig ./my-figma-export --node "1:23" --out ./out.png
225
+ refig ./my-figma-export --node "1:23" --format png
221
226
 
222
227
  # Export all nodes that have exportSettings (REST JSON or .fig)
223
- refig <input> --export-all --out <output-dir>
228
+ refig <input> --export-all [--out <output-dir>]
224
229
  ```
225
230
 
226
231
  ### Examples
227
232
 
228
233
  ```sh
229
- # Render a node from a .fig file
230
- refig ./design.fig --node "1:23" --out ./out.png
231
-
232
- # Render from REST API JSON
233
- refig ./figma-response.json --node "1:23" --out ./out.svg
234
+ # Instant usage: omit --out to write to OS temp directory (output path printed)
235
+ refig ./design.fig --node "1:23" --format png
236
+ refig ./figma-response.json --node "1:23" --format svg
234
237
 
235
238
  # Directory with document.json (and optionally images/): one path instead of response + --images
236
- refig ./my-figma-export --node "1:23" --out ./out.png
239
+ refig ./my-figma-export --node "1:23" --format png
237
240
  # (my-figma-export/document.json, my-figma-export/images/)
238
241
 
239
242
  # Explicit images directory (when not using a project directory)
240
- refig ./figma-response.json --images ./downloaded-images --node "1:23" --out ./out.png
243
+ refig ./figma-response.json --images ./downloaded-images --node "1:23" --format png
241
244
 
242
245
  # Export all: render every node that has export settings (see below)
243
- refig ./figma-response.json --export-all --out ./exports
244
- refig ./design.fig --export-all --out ./exports
246
+ refig ./figma-response.json --export-all
247
+ refig ./design.fig --export-all
245
248
 
246
249
  # Scale 2x, custom dimensions
247
- refig ./design.fig --node "1:23" --out ./out.png --width 512 --height 512 --scale 2
250
+ refig ./design.fig --node "1:23" --format png --width 512 --height 512 --scale 2
251
+
252
+ # Deterministic output: provide --out (useful for CI or saving into a known path)
253
+ refig ./design.fig --node "1:23" --out ./out.png
254
+ refig ./figma-response.json --export-all --out ./exports
248
255
 
249
256
  # No-install (run without installing)
250
- npx @grida/refig ./design.fig --node "1:23" --out ./out.png
251
- pnpm dlx @grida/refig ./design.fig --node "1:23" --out ./out.png
257
+ npx @grida/refig ./design.fig --node "1:23" --format png
258
+ pnpm dlx @grida/refig ./design.fig --export-all
252
259
  ```
253
260
 
254
261
  ### Quick test via `figma_archive.py` (REST API → `document.json` + `images/`)
@@ -275,10 +282,10 @@ This writes:
275
282
 
276
283
  ```sh
277
284
  # Single node
278
- refig ./my-figma-export --node "1:23" --out ./out.png
285
+ refig ./my-figma-export --node "1:23" --format png
279
286
 
280
287
  # Or export everything with Figma export presets
281
- refig ./my-figma-export --export-all --out ./exports
288
+ refig ./my-figma-export --export-all
282
289
  ```
283
290
 
284
291
  ### Export all (`--export-all`)
@@ -291,17 +298,17 @@ With **`--export-all`**, refig walks the document and renders every node that ha
291
298
 
292
299
  ### Flags
293
300
 
294
- | Flag | Required | Default | Description |
295
- | ---------------- | -------- | ------------------------------- | --------------------------------------------------------------------------------------------- |
296
- | `<input>` | yes | | Path to `.fig`, JSON file, or directory containing `document.json` (and optionally `images/`) |
297
- | `--images <dir>` | no | | Directory of image assets for REST document (ignored if `<input>` is a dir with `images/`) |
298
- | `--node <id>` | yes\* | | Figma node ID to render (\*omit when using `--export-all`) |
299
- | `--out <path>` | yes | | Output file path (single node) or output directory (`--export-all`) |
300
- | `--export-all` | no | | Export every node with exportSettings (REST JSON or .fig); `--out` is a directory |
301
- | `--format <fmt>` | no | inferred from `--out` extension | `png`, `jpeg`, `webp`, `pdf`, `svg` (single-node only) |
302
- | `--width <px>` | no | `1024` | Viewport width (single-node only) |
303
- | `--height <px>` | no | `1024` | Viewport height (single-node only) |
304
- | `--scale <n>` | no | `1` | Raster scale factor (single-node only) |
301
+ | Flag | Required | Default | Description |
302
+ | ---------------- | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
303
+ | `<input>` | yes | | Path to `.fig`, JSON file, or directory containing `document.json` (and optionally `images/`) |
304
+ | `--images <dir>` | no | | Directory of image assets for REST document (ignored if `<input>` is a dir with `images/`) |
305
+ | `--node <id>` | yes\* | | Figma node ID to render (\*omit when using `--export-all`) |
306
+ | `--out <path>` | no | OS temp dir when omitted | Output file path (single node) or output directory (`--export-all`). When omitted, writes to the OS temp directory (valid with `--export-all` or with both `--format` and `--node`). |
307
+ | `--export-all` | no | | Export every node with exportSettings (REST JSON or .fig); `--out` is a directory |
308
+ | `--format <fmt>` | no | inferred from `--out` extension | `png`, `jpeg`, `webp`, `pdf`, `svg` (single-node only; required when `--out` is omitted) |
309
+ | `--width <px>` | no | `1024` | Viewport width (single-node only) |
310
+ | `--height <px>` | no | `1024` | Viewport height (single-node only) |
311
+ | `--scale <n>` | no | `1` | Raster scale factor (single-node only) |
305
312
 
306
313
  ## Architecture
307
314
 
@@ -332,9 +339,9 @@ REST JSON ───┘
332
339
  **CLI** — You can pass images in two ways:
333
340
 
334
341
  - **`--images <dir>`** — Explicit images directory. Files are keyed by filename without extension (e.g. `a1b2c3d4.png` → ref `a1b2c3d4`). Use when the document is a separate file:
335
- `refig ./figma-response.json --images ./downloaded-images --node "1:23" --out ./out.png`
342
+ `refig ./figma-response.json --images ./downloaded-images --node "1:23" --format png`
336
343
  - **Directory input** — Pass a single directory that contains **`document.json`** (REST response) and optionally **`images/`**. No need to pass `--images` separately:
337
- `refig ./my-figma-export --node "1:23" --out ./out.png`
344
+ `refig ./my-figma-export --node "1:23" --format png`
338
345
  (expects `my-figma-export/document.json` and, if present, `my-figma-export/images/`.)
339
346
 
340
347
  For **`.fig`** input, images are embedded in the file; no extra images directory is needed. For **REST** input, use `--images` or a project directory with `images/` to render IMAGE fills correctly.
@@ -372,7 +379,7 @@ From the package root:
372
379
 
373
380
  1. Install dependencies and build: `pnpm install && pnpm build`
374
381
  2. Link the package so the `refig` CLI is available: `pnpm link --global`
375
- 3. Run the `refig` command from anywhere to test (e.g. `refig ./fixture.json --node "1:1" --out ./out.png`)
382
+ 3. Run the `refig` command from anywhere to test (e.g. `refig ./fixture.json --node "1:1" --format png`)
376
383
 
377
384
  To unlink: `pnpm unlink --global`.
378
385
 
package/dist/browser.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  collectExportsFromDocument,
5
5
  exportSettingToRenderOptions,
6
6
  resolveMimeType
7
- } from "./chunk-PK5L35ID.mjs";
7
+ } from "./chunk-YI7PIULZ.mjs";
8
8
  export {
9
9
  FigmaDocument,
10
10
  FigmaRenderer,
@@ -13749,11 +13749,19 @@ var iofigma;
13749
13749
  function hasGeometryTrait(node2) {
13750
13750
  return "fillGeometry" in node2 || "strokeGeometry" in node2;
13751
13751
  }
13752
+ function getParentBounds(node2) {
13753
+ const box = "absoluteBoundingBox" in node2 ? node2.absoluteBoundingBox : void 0;
13754
+ const sz = "size" in node2 ? node2.size : void 0;
13755
+ return {
13756
+ width: box?.width ?? sz?.x ?? 0,
13757
+ height: box?.height ?? sz?.y ?? 0
13758
+ };
13759
+ }
13752
13760
  function createVectorNodeFromPath(pathData, geometry, parentNode, childId, name, options) {
13753
13761
  if (!pathData) return null;
13754
13762
  try {
13755
13763
  const vectorNetwork = index_default2.fromSVGPathData(pathData);
13756
- const bbox = index_default2.getBBox(vectorNetwork);
13764
+ const { width, height } = getParentBounds(parentNode);
13757
13765
  const strokeAsFill = options.strokeAsFill === true;
13758
13766
  return {
13759
13767
  id: childId,
@@ -13767,10 +13775,10 @@ var iofigma;
13767
13775
  }),
13768
13776
  ...positioning_trait({
13769
13777
  relativeTransform: [
13770
- [1, 0, bbox.x],
13771
- [0, 1, bbox.y]
13778
+ [1, 0, 0],
13779
+ [0, 1, 0]
13772
13780
  ],
13773
- size: { x: bbox.width, y: bbox.height }
13781
+ size: { x: width, y: height }
13774
13782
  }),
13775
13783
  ...strokeAsFill ? {
13776
13784
  ...fills_trait(
@@ -13794,8 +13802,8 @@ var iofigma;
13794
13802
  ..."effects" in parentNode && parentNode.effects ? effects_trait(parentNode.effects) : effects_trait(void 0),
13795
13803
  type: "vector",
13796
13804
  vector_network: vectorNetwork,
13797
- layout_target_width: bbox.width,
13798
- layout_target_height: bbox.height,
13805
+ layout_target_width: width,
13806
+ layout_target_height: height,
13799
13807
  fill_rule: map.windingRuleMap[geometry.windingRule] ?? "nonzero"
13800
13808
  };
13801
13809
  } catch (e) {
@@ -13806,13 +13814,73 @@ var iofigma;
13806
13814
  return null;
13807
13815
  }
13808
13816
  }
13817
+ function createPathNodeFromPath(pathData, geometry, parentNode, childId, name, options) {
13818
+ if (!pathData) return null;
13819
+ try {
13820
+ const { width, height } = getParentBounds(parentNode);
13821
+ const strokeAsFill = options.strokeAsFill === true;
13822
+ return {
13823
+ id: childId,
13824
+ ...base_node_trait({
13825
+ name,
13826
+ visible: "visible" in parentNode ? parentNode.visible : true,
13827
+ locked: "locked" in parentNode ? parentNode.locked : false,
13828
+ rotation: 0,
13829
+ opacity: "opacity" in parentNode && parentNode.opacity !== void 0 ? parentNode.opacity : 1,
13830
+ blendMode: "blendMode" in parentNode && parentNode.blendMode ? parentNode.blendMode : "NORMAL"
13831
+ }),
13832
+ ...positioning_trait({
13833
+ relativeTransform: [
13834
+ [1, 0, 0],
13835
+ [0, 1, 0]
13836
+ ],
13837
+ size: { x: width, y: height }
13838
+ }),
13839
+ ...strokeAsFill ? {
13840
+ ...fills_trait(
13841
+ parentNode.strokes ?? [],
13842
+ context,
13843
+ imageRefsUsed
13844
+ ),
13845
+ ...stroke_trait(
13846
+ { strokes: [], strokeWeight: 0 },
13847
+ context,
13848
+ imageRefsUsed
13849
+ )
13850
+ } : {
13851
+ ...options.useFill ? fills_trait(parentNode.fills, context, imageRefsUsed) : {},
13852
+ ...options.useStroke ? stroke_trait(parentNode, context, imageRefsUsed) : stroke_trait(
13853
+ { strokes: [], strokeWeight: 0 },
13854
+ context,
13855
+ imageRefsUsed
13856
+ )
13857
+ },
13858
+ ..."effects" in parentNode && parentNode.effects ? effects_trait(parentNode.effects) : effects_trait(void 0),
13859
+ type: "path",
13860
+ data: pathData,
13861
+ layout_target_width: width,
13862
+ layout_target_height: height,
13863
+ fill_rule: map.windingRuleMap[geometry.windingRule] ?? "nonzero"
13864
+ };
13865
+ } catch (e) {
13866
+ console.warn(`Failed to create path node (${name}):`, e);
13867
+ return null;
13868
+ }
13869
+ }
13809
13870
  function processFillGeometries(node2, parentGridaId, nodeTypeName) {
13810
13871
  if (!node2.fillGeometry?.length) return [];
13811
13872
  const childIds = [];
13812
13873
  node2.fillGeometry.forEach((geometry, idx) => {
13813
13874
  const childId = `${parentGridaId}_fill_${idx}`;
13814
13875
  const name = `${node2.name || nodeTypeName} Fill ${idx + 1}`;
13815
- const childNode = createVectorNodeFromPath(
13876
+ const childNode = context.prefer_path_for_geometry ? createPathNodeFromPath(
13877
+ geometry.path ?? "",
13878
+ geometry,
13879
+ node2,
13880
+ childId,
13881
+ name,
13882
+ { useFill: true, useStroke: false }
13883
+ ) : createVectorNodeFromPath(
13816
13884
  geometry.path ?? "",
13817
13885
  geometry,
13818
13886
  node2,
@@ -13833,7 +13901,14 @@ var iofigma;
13833
13901
  node2.strokeGeometry.forEach((geometry, idx) => {
13834
13902
  const childId = `${parentGridaId}_stroke_${idx}`;
13835
13903
  const name = `${node2.name || nodeTypeName} Stroke ${idx + 1}`;
13836
- const childNode = createVectorNodeFromPath(
13904
+ const childNode = context.prefer_path_for_geometry ? createPathNodeFromPath(
13905
+ geometry.path ?? "",
13906
+ geometry,
13907
+ node2,
13908
+ childId,
13909
+ name,
13910
+ { useFill: false, useStroke: false, strokeAsFill: true }
13911
+ ) : createVectorNodeFromPath(
13837
13912
  geometry.path ?? "",
13838
13913
  geometry,
13839
13914
  node2,
@@ -14082,7 +14157,7 @@ var iofigma;
14082
14157
  case "REGULAR_POLYGON":
14083
14158
  case "STAR":
14084
14159
  case "VECTOR": {
14085
- const useRestVectorNetwork = context.disable_volatile_apis !== true && "vectorNetwork" in node && node.vectorNetwork != null;
14160
+ const useRestVectorNetwork = context.prefer_path_for_geometry !== true && context.disable_volatile_apis !== true && "vectorNetwork" in node && node.vectorNetwork != null;
14086
14161
  if (useRestVectorNetwork) {
14087
14162
  try {
14088
14163
  const ir = restful2.map.normalizeRestVectorNetworkToIR(
@@ -15866,7 +15941,8 @@ function exportSettingToRenderOptions(node, setting) {
15866
15941
  JPG: "jpeg",
15867
15942
  PNG: "png",
15868
15943
  SVG: "svg",
15869
- PDF: "pdf"
15944
+ PDF: "pdf",
15945
+ WEBP: "webp"
15870
15946
  };
15871
15947
  const format = formatMap[setting.format] ?? "png";
15872
15948
  const constraint = setting.constraint;
@@ -15951,6 +16027,7 @@ function restJsonToSceneJson(json, rootNodeId, images) {
15951
16027
  const resolveImageSrc = images && (Object.keys(images).length > 0 ? (ref) => ref in images ? `res://images/${ref}` : null : void 0);
15952
16028
  const buildContext = (overrides) => ({
15953
16029
  gradient_id_generator: baseGradientGen,
16030
+ prefer_path_for_geometry: true,
15954
16031
  ...resolveImageSrc && { resolve_image_src: resolveImageSrc },
15955
16032
  ...overrides
15956
16033
  });
@@ -16089,6 +16166,7 @@ function figBytesToSceneJson(figBytes, rootNodeId) {
16089
16166
  node_id_generator: () => `refig-${++counter}`,
16090
16167
  gradient_id_generator: () => `grad-${++counter}`,
16091
16168
  preserve_figma_ids: true,
16169
+ prefer_path_for_geometry: true,
16092
16170
  ...resolveImageSrc && { resolve_image_src: resolveImageSrc }
16093
16171
  };
16094
16172
  const { document: packed } = iofigma.kiwi.convertPageToScene(page, context);
package/dist/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  exportSettingToRenderOptions,
7
7
  figFileToRestLikeDocument,
8
8
  iofigma
9
- } from "./chunk-PK5L35ID.mjs";
9
+ } from "./chunk-YI7PIULZ.mjs";
10
10
 
11
11
  // cli.ts
12
12
  import {
@@ -15,9 +15,11 @@ import {
15
15
  mkdirSync,
16
16
  writeFileSync,
17
17
  existsSync,
18
- statSync
18
+ statSync,
19
+ mkdtempSync
19
20
  } from "fs";
20
21
  import path from "path";
22
+ import { tmpdir } from "os";
21
23
  import { program } from "commander";
22
24
  var FORMAT_SET = /* @__PURE__ */ new Set(["png", "jpeg", "webp", "pdf", "svg"]);
23
25
  var DOCUMENT_JSON = "document.json";
@@ -31,6 +33,7 @@ var EXT_BY_FORMAT = {
31
33
  png: "png",
32
34
  jpeg: "jpeg",
33
35
  jpg: "jpeg",
36
+ webp: "webp",
34
37
  svg: "svg",
35
38
  pdf: "pdf"
36
39
  };
@@ -174,9 +177,9 @@ async function main() {
174
177
  ).argument(
175
178
  "<input>",
176
179
  "Path to .fig, JSON file (REST API response), or directory containing document.json (and optionally images/)"
177
- ).requiredOption(
180
+ ).option(
178
181
  "--out <path>",
179
- "Output file path (single node) or output directory (--export-all)"
182
+ "Output file path (single node) or output directory (--export-all); when omitted, uses OS temp directory (valid with --export-all or with both --format and --node)"
180
183
  ).option(
181
184
  "--images <dir>",
182
185
  "Directory of image files for REST API document (optional; not used if <input> is a dir with images/)"
@@ -198,9 +201,6 @@ async function main() {
198
201
  const exportAll = options.exportAll === true;
199
202
  const nodeId = String(options.node ?? "").trim();
200
203
  const explicitImagesDir = typeof options.images === "string" ? options.images : void 0;
201
- if (!outPath) {
202
- program.error("--out is required");
203
- }
204
204
  const { documentPath, imagesDir, isRestJson } = resolveInput(
205
205
  input.trim(),
206
206
  explicitImagesDir
@@ -209,17 +209,20 @@ async function main() {
209
209
  if (nodeId) {
210
210
  program.error("--node must not be used with --export-all");
211
211
  }
212
- const outDir = path.resolve(outPath);
213
- if (existsSync(outDir)) {
214
- const stat = statSync(outDir);
215
- if (!stat.isDirectory()) {
216
- program.error(
217
- "--out must be a directory when using --export-all"
218
- );
212
+ const outDir = outPath ? (() => {
213
+ const resolved = path.resolve(outPath);
214
+ if (existsSync(resolved)) {
215
+ const stat = statSync(resolved);
216
+ if (!stat.isDirectory()) {
217
+ program.error(
218
+ "--out must be a directory when using --export-all"
219
+ );
220
+ }
221
+ } else {
222
+ mkdirSync(resolved, { recursive: true });
219
223
  }
220
- } else {
221
- mkdirSync(outDir, { recursive: true });
222
- }
224
+ return resolved;
225
+ })() : mkdtempSync(path.join(tmpdir(), "refig-export-"));
223
226
  await runExportAll(
224
227
  documentPath,
225
228
  outDir,
@@ -234,8 +237,28 @@ async function main() {
234
237
  const width = Number(options.width ?? 1024);
235
238
  const height = Number(options.height ?? 1024);
236
239
  const scale = Number(options.scale ?? 1);
237
- await runSingleNode(documentPath, nodeId, path.resolve(outPath), {
238
- format: typeof options.format === "string" ? options.format : void 0,
240
+ const formatOption = typeof options.format === "string" ? options.format : void 0;
241
+ let singleOutPath;
242
+ if (outPath) {
243
+ singleOutPath = path.resolve(outPath);
244
+ } else {
245
+ if (!formatOption) {
246
+ program.error(
247
+ "When --out is omitted, both --node and --format are required"
248
+ );
249
+ }
250
+ const format = formatOption.toLowerCase();
251
+ if (!FORMAT_SET.has(format)) {
252
+ program.error(`Unsupported --format "${format}"`);
253
+ }
254
+ const ext = EXT_BY_FORMAT[format] ?? "png";
255
+ singleOutPath = path.join(
256
+ tmpdir(),
257
+ `refig-${sanitizeForFilename(nodeId)}.${ext}`
258
+ );
259
+ }
260
+ await runSingleNode(documentPath, nodeId, singleOutPath, {
261
+ format: formatOption,
239
262
  width,
240
263
  height,
241
264
  scale,
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  collectExportsFromDocument,
5
5
  exportSettingToRenderOptions,
6
6
  resolveMimeType
7
- } from "./chunk-PK5L35ID.mjs";
7
+ } from "./chunk-YI7PIULZ.mjs";
8
8
 
9
9
  // index.ts
10
10
  import { readFileSync } from "fs";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grida/refig",
3
- "version": "0.0.1",
3
+ "version": "0.0.3",
4
4
  "private": false,
5
5
  "description": "Headless Figma renderer — render .fig and REST API JSON to PNG/JPEG/WebP/PDF/SVG",
6
6
  "keywords": [
@@ -55,7 +55,7 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@grida/canvas-wasm": "0.90.0-canary.7",
58
+ "@grida/canvas-wasm": "0.90.0-canary.8",
59
59
  "commander": "^12.1.0"
60
60
  },
61
61
  "devDependencies": {