@grida/refig 0.0.0 → 0.0.2

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,10 +1,28 @@
1
1
  # `@grida/refig`
2
2
 
3
- > **re**nder **fig**ma — headless Figma renderer in the spirit of [`resvg`](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
- Render Figma documents to **PNG, JPEG, WebP, PDF, and SVG** without Figma Desktop and without a browser.
5
+ Render Figma documents to **PNG, JPEG, WebP, PDF, and SVG** in **Node.js (no browser required)** or directly in the **browser**.
6
6
 
7
- Pass a `.fig` file or a Figma REST API JSON response, pick a node, get pixels.
7
+ Use a `.fig` export (offline) or a Figma REST API file JSON response (`GET /v1/files/:key`), pick a node ID, and get pixels.
8
+
9
+ ## Features (checklist)
10
+
11
+ - [x] Render from **`.fig` files** (offline / no API calls)
12
+ - [x] Render from **Figma REST API JSON** (bring your own auth + HTTP client)
13
+ - [x] Output formats: **PNG, JPEG, WebP, PDF, SVG**
14
+ - [x] **CLI** (`refig`) and **library API** (`FigmaDocument`, `FigmaRenderer`)
15
+ - [x] **Node.js** + **browser** entrypoints (`@grida/refig`, `@grida/refig/browser`)
16
+ - [x] IMAGE fills supported via **embedded `.fig` images** or a **local `images/` directory** for REST JSON
17
+ - [x] Batch export with **`--export-all`** (renders nodes with Figma export presets)
18
+ - [x] WASM + Skia-backed renderer via `@grida/canvas-wasm`
19
+
20
+ ## Use cases
21
+
22
+ - Export assets in CI (deterministic, no network calls required)
23
+ - Generate thumbnails / previews from `.fig` or REST JSON
24
+ - Offline / air-gapped rendering from `.fig` exports
25
+ - In-browser previews with `@grida/refig/browser`
8
26
 
9
27
  ## Install
10
28
 
@@ -26,7 +44,7 @@ Both entrypoints export the same core API (`FigmaDocument`, `FigmaRenderer`, typ
26
44
  ### Render from a `.fig` file
27
45
 
28
46
  ```ts
29
- import { readFileSync, writeFileSync } from "node:fs";
47
+ import { writeFileSync } from "node:fs";
30
48
  import { FigmaDocument, FigmaRenderer } from "@grida/refig";
31
49
 
32
50
  const doc = FigmaDocument.fromFile("path/to/file.fig");
@@ -92,6 +110,7 @@ renderer.dispose();
92
110
  ```ts
93
111
  import { FigmaDocument, FigmaRenderer } from "@grida/refig/browser";
94
112
 
113
+ // `file` is a File from <input type="file">, drag-and-drop, etc.
95
114
  // Uint8Array from a File input, fetch(), or drag-and-drop
96
115
  const figBytes: Uint8Array = await file
97
116
  .arrayBuffer()
@@ -172,6 +191,13 @@ interface RefigRenderResult {
172
191
  pnpm add -g @grida/refig
173
192
  ```
174
193
 
194
+ Or run without installing:
195
+
196
+ ```sh
197
+ npx @grida/refig <input> --node <node-id> --out <path>
198
+ pnpm dlx @grida/refig <input> --node <node-id> --out <path>
199
+ ```
200
+
175
201
  ### Usage
176
202
 
177
203
  **`<input>`** can be:
@@ -220,10 +246,41 @@ refig ./design.fig --export-all --out ./exports
220
246
  # Scale 2x, custom dimensions
221
247
  refig ./design.fig --node "1:23" --out ./out.png --width 512 --height 512 --scale 2
222
248
 
223
- # No-install (CI one-liner)
249
+ # No-install (run without installing)
250
+ npx @grida/refig ./design.fig --node "1:23" --out ./out.png
224
251
  pnpm dlx @grida/refig ./design.fig --node "1:23" --out ./out.png
225
252
  ```
226
253
 
254
+ ### Quick test via `figma_archive.py` (REST API → `document.json` + `images/`)
255
+
256
+ If you want an end-to-end test from a real Figma file using the REST API, you can generate a local “project directory” that refig can consume directly.
257
+
258
+ 1. Archive a Figma file (stdlib-only Python script):
259
+
260
+ - Script: [`figma_archive.py` (gist)](https://gist.github.com/softmarshmallow/27ad65dfa5babc2c67b41740f1f05791)
261
+ - (For repo contributors, it’s also in this monorepo at `.tools/figma_archive.py`.)
262
+ - Save the script locally as `figma_archive.py`, then run:
263
+
264
+ ```sh
265
+ # File key is the "<key>" part of `https://www.figma.com/file/<key>/...`
266
+ python3 figma_archive.py --x-figma-token "<token>" --filekey "<key>" --archive-dir ./my-figma-export
267
+ ```
268
+
269
+ This writes:
270
+
271
+ - `./my-figma-export/document.json` (with `geometry=paths`)
272
+ - `./my-figma-export/images/<ref>.<ext>` (image fills downloaded from `/v1/files/:key/images`)
273
+
274
+ 2. Render using the directory as `<input>`:
275
+
276
+ ```sh
277
+ # Single node
278
+ refig ./my-figma-export --node "1:23" --out ./out.png
279
+
280
+ # Or export everything with Figma export presets
281
+ refig ./my-figma-export --export-all --out ./exports
282
+ ```
283
+
227
284
  ### Export all (`--export-all`)
228
285
 
229
286
  With **`--export-all`**, refig walks the document and renders every node that has [Figma export settings](https://www.figma.com/developers/api#exportsetting-type) — one file per (node, setting), using that setting’s format, suffix, and constraint. Both **REST API JSON** (e.g. `GET /v1/files/:key`) and **`.fig` files** are supported when the file includes export settings.
@@ -282,15 +339,6 @@ REST JSON ───┘
282
339
 
283
340
  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.
284
341
 
285
- ## Features
286
-
287
- - **Multiple output formats** — `png`, `jpeg`, `webp`, `pdf`, `svg`
288
- - **`.fig` file input** — render from exported `.fig` without API calls
289
- - **REST API JSON input** — render from document JSON you already have
290
- - **CI-friendly** — headless, deterministic, no browser required
291
- - **Browser-compatible** — `@grida/refig/browser` works in any modern browser
292
- - **WASM-powered** — Skia-backed rendering for pixel-accurate output
293
-
294
342
  ## Not planned
295
343
 
296
344
  - **Figma API fetching / auth** — bring your own tokens and HTTP client
@@ -316,7 +364,7 @@ Yes. Import from `@grida/refig/browser`. The core renderer uses `@grida/canvas-w
316
364
 
317
365
  ### What about fonts?
318
366
 
319
- The WASM runtime ships with embedded fallback fonts. Custom font loading (Google Fonts, local directories) is planned but not yet available.
367
+ The WASM runtime ships with embedded fallback fonts (Geist / Geist Mono). **`loadFigmaDefaultFonts`** is enabled by default: the renderer loads the Figma default font set (Inter, Noto Sans KR/JP/SC, and optionally Noto Sans TC/HK and Noto Color Emoji) from CDN and registers them as fallbacks before the first render, so mixed-script and CJK text avoid tofu. Set **`loadFigmaDefaultFonts: false`** to disable (e.g. to avoid network or use only embedded fonts). Custom or other Google Fonts are **not** loaded by the renderer; the user is responsible for fetching font bytes and registering them with the canvas if needed.
320
368
 
321
369
  ## Contributing
322
370
 
@@ -14,6 +14,14 @@ interface RefigRendererOptions {
14
14
  * @default true
15
15
  */
16
16
  useEmbeddedFonts?: boolean;
17
+ /**
18
+ * When true (default), the renderer ensures Figma default fonts (Inter, Noto Sans KR/JP/SC, etc.)
19
+ * are loaded from CDN and registered with the canvas before any scene is loaded.
20
+ * Reduces tofu for mixed-script and CJK text. Set to false to skip (e.g. to avoid network or use only embedded fonts).
21
+ * Custom fonts remain the user's responsibility.
22
+ * @default true
23
+ */
24
+ loadFigmaDefaultFonts?: boolean;
17
25
  /**
18
26
  * Map of Figma image ref (hash) to image bytes.
19
27
  * Used for REST API and .fig input so IMAGE fills render correctly.
package/dist/browser.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  collectExportsFromDocument,
5
5
  exportSettingToRenderOptions,
6
6
  resolveMimeType
7
- } from "./chunk-DAHUXARL.mjs";
7
+ } from "./chunk-YI7PIULZ.mjs";
8
8
  export {
9
9
  FigmaDocument,
10
10
  FigmaRenderer,
@@ -13393,6 +13393,61 @@ var iofigma;
13393
13393
  ...map2.blendModeMap,
13394
13394
  PASS_THROUGH: "pass-through"
13395
13395
  };
13396
+ function normalizeRestVectorNetworkToIR(rest) {
13397
+ if (!Array.isArray(rest.vertices) || !Array.isArray(rest.segments) || !Array.isArray(rest.regions)) {
13398
+ return null;
13399
+ }
13400
+ const vertexCount = rest.vertices.length;
13401
+ const segmentCount = rest.segments.length;
13402
+ for (const seg of rest.segments) {
13403
+ if (typeof seg.start !== "number" || typeof seg.end !== "number" || seg.start < 0 || seg.start >= vertexCount || seg.end < 0 || seg.end >= vertexCount || !seg.startTangent || !seg.endTangent) {
13404
+ return null;
13405
+ }
13406
+ }
13407
+ for (const region of rest.regions) {
13408
+ if (!Array.isArray(region.loops)) return null;
13409
+ for (const loop of region.loops) {
13410
+ if (!Array.isArray(loop)) return null;
13411
+ for (const segIdx of loop) {
13412
+ if (typeof segIdx !== "number" || segIdx < 0 || segIdx >= segmentCount) {
13413
+ return null;
13414
+ }
13415
+ }
13416
+ }
13417
+ }
13418
+ const vertices = rest.vertices.map(
13419
+ (v) => ({
13420
+ x: v.position?.x ?? 0,
13421
+ y: v.position?.y ?? 0,
13422
+ styleID: 0
13423
+ })
13424
+ );
13425
+ const segments = rest.segments.map(
13426
+ (seg) => ({
13427
+ styleID: 0,
13428
+ start: {
13429
+ vertex: seg.start,
13430
+ dx: seg.startTangent.x,
13431
+ dy: seg.startTangent.y
13432
+ },
13433
+ end: {
13434
+ vertex: seg.end,
13435
+ dx: seg.endTangent.x,
13436
+ dy: seg.endTangent.y
13437
+ }
13438
+ })
13439
+ );
13440
+ const regions = rest.regions.map(
13441
+ (region) => {
13442
+ const wr = region.windingRule?.toUpperCase?.();
13443
+ const windingRule = wr === "EVENODD" || wr === "ODD" ? "ODD" : "NONZERO";
13444
+ const loops = region.loops.map((loop) => ({ segments: loop }));
13445
+ return { styleID: 0, windingRule, loops };
13446
+ }
13447
+ );
13448
+ return { vertices, segments, regions };
13449
+ }
13450
+ map2.normalizeRestVectorNetworkToIR = normalizeRestVectorNetworkToIR;
13396
13451
  })(map = restful2.map || (restful2.map = {}));
13397
13452
  let factory;
13398
13453
  ((factory2) => {
@@ -13694,11 +13749,20 @@ var iofigma;
13694
13749
  function hasGeometryTrait(node2) {
13695
13750
  return "fillGeometry" in node2 || "strokeGeometry" in node2;
13696
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
+ }
13697
13760
  function createVectorNodeFromPath(pathData, geometry, parentNode, childId, name, options) {
13698
13761
  if (!pathData) return null;
13699
13762
  try {
13700
13763
  const vectorNetwork = index_default2.fromSVGPathData(pathData);
13701
- const bbox = index_default2.getBBox(vectorNetwork);
13764
+ const { width, height } = getParentBounds(parentNode);
13765
+ const strokeAsFill = options.strokeAsFill === true;
13702
13766
  return {
13703
13767
  id: childId,
13704
13768
  ...base_node_trait({
@@ -13711,22 +13775,35 @@ var iofigma;
13711
13775
  }),
13712
13776
  ...positioning_trait({
13713
13777
  relativeTransform: [
13714
- [1, 0, bbox.x],
13715
- [0, 1, bbox.y]
13778
+ [1, 0, 0],
13779
+ [0, 1, 0]
13716
13780
  ],
13717
- size: { x: bbox.width, y: bbox.height }
13781
+ size: { x: width, y: height }
13718
13782
  }),
13719
- ...options.useFill ? fills_trait(parentNode.fills, context, imageRefsUsed) : {},
13720
- ...options.useStroke ? stroke_trait(parentNode, context, imageRefsUsed) : stroke_trait(
13721
- { strokes: [], strokeWeight: 0 },
13722
- context,
13723
- imageRefsUsed
13724
- ),
13783
+ ...strokeAsFill ? {
13784
+ ...fills_trait(
13785
+ parentNode.strokes ?? [],
13786
+ context,
13787
+ imageRefsUsed
13788
+ ),
13789
+ ...stroke_trait(
13790
+ { strokes: [], strokeWeight: 0 },
13791
+ context,
13792
+ imageRefsUsed
13793
+ )
13794
+ } : {
13795
+ ...options.useFill ? fills_trait(parentNode.fills, context, imageRefsUsed) : {},
13796
+ ...options.useStroke ? stroke_trait(parentNode, context, imageRefsUsed) : stroke_trait(
13797
+ { strokes: [], strokeWeight: 0 },
13798
+ context,
13799
+ imageRefsUsed
13800
+ )
13801
+ },
13725
13802
  ..."effects" in parentNode && parentNode.effects ? effects_trait(parentNode.effects) : effects_trait(void 0),
13726
13803
  type: "vector",
13727
13804
  vector_network: vectorNetwork,
13728
- layout_target_width: bbox.width,
13729
- layout_target_height: bbox.height,
13805
+ layout_target_width: width,
13806
+ layout_target_height: height,
13730
13807
  fill_rule: map.windingRuleMap[geometry.windingRule] ?? "nonzero"
13731
13808
  };
13732
13809
  } catch (e) {
@@ -13737,13 +13814,73 @@ var iofigma;
13737
13814
  return null;
13738
13815
  }
13739
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
+ }
13740
13870
  function processFillGeometries(node2, parentGridaId, nodeTypeName) {
13741
13871
  if (!node2.fillGeometry?.length) return [];
13742
13872
  const childIds = [];
13743
13873
  node2.fillGeometry.forEach((geometry, idx) => {
13744
13874
  const childId = `${parentGridaId}_fill_${idx}`;
13745
13875
  const name = `${node2.name || nodeTypeName} Fill ${idx + 1}`;
13746
- 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(
13747
13884
  geometry.path ?? "",
13748
13885
  geometry,
13749
13886
  node2,
@@ -13764,13 +13901,20 @@ var iofigma;
13764
13901
  node2.strokeGeometry.forEach((geometry, idx) => {
13765
13902
  const childId = `${parentGridaId}_stroke_${idx}`;
13766
13903
  const name = `${node2.name || nodeTypeName} Stroke ${idx + 1}`;
13767
- const childNode = createVectorNodeFromPath(
13904
+ const childNode = context.prefer_path_for_geometry ? createPathNodeFromPath(
13768
13905
  geometry.path ?? "",
13769
13906
  geometry,
13770
13907
  node2,
13771
13908
  childId,
13772
13909
  name,
13773
- { useFill: false, useStroke: true }
13910
+ { useFill: false, useStroke: false, strokeAsFill: true }
13911
+ ) : createVectorNodeFromPath(
13912
+ geometry.path ?? "",
13913
+ geometry,
13914
+ node2,
13915
+ childId,
13916
+ name,
13917
+ { useFill: false, useStroke: false, strokeAsFill: true }
13774
13918
  );
13775
13919
  if (childNode) {
13776
13920
  nodes[childId] = childNode;
@@ -14013,6 +14157,37 @@ var iofigma;
14013
14157
  case "REGULAR_POLYGON":
14014
14158
  case "STAR":
14015
14159
  case "VECTOR": {
14160
+ const useRestVectorNetwork = context.prefer_path_for_geometry !== true && context.disable_volatile_apis !== true && "vectorNetwork" in node && node.vectorNetwork != null;
14161
+ if (useRestVectorNetwork) {
14162
+ try {
14163
+ const ir = restful2.map.normalizeRestVectorNetworkToIR(
14164
+ node.vectorNetwork
14165
+ );
14166
+ if (ir) {
14167
+ const gridaVectorNetwork = {
14168
+ vertices: ir.vertices.map((v) => [v.x, v.y]),
14169
+ segments: ir.segments.map((seg) => ({
14170
+ a: seg.start.vertex,
14171
+ b: seg.end.vertex,
14172
+ ta: [seg.start.dx, seg.start.dy],
14173
+ tb: [seg.end.dx, seg.end.dy]
14174
+ }))
14175
+ };
14176
+ return {
14177
+ id: gridaId,
14178
+ ...base_node_trait(node),
14179
+ ...positioning_trait(node),
14180
+ ...fills_trait(node.fills, context, imageRefsUsed),
14181
+ ...stroke_trait(node, context, imageRefsUsed),
14182
+ ...corner_radius_trait(node),
14183
+ ...effects_trait(node.effects),
14184
+ type: "vector",
14185
+ vector_network: gridaVectorNetwork
14186
+ };
14187
+ }
14188
+ } catch {
14189
+ }
14190
+ }
14016
14191
  return {
14017
14192
  id: gridaId,
14018
14193
  ...base_node_trait(node),
@@ -14363,7 +14538,11 @@ var iofigma;
14363
14538
  const f = fmt(s.imageType);
14364
14539
  if (!f) return null;
14365
14540
  const c = s.constraint;
14366
- return { format: f, suffix: s.suffix ?? "", constraint: { type: cstr(c?.type), value: c?.value ?? 1 } };
14541
+ return {
14542
+ format: f,
14543
+ suffix: s.suffix ?? "",
14544
+ constraint: { type: cstr(c?.type), value: c?.value ?? 1 }
14545
+ };
14367
14546
  }).filter((x) => x !== null);
14368
14547
  return exportSettings.length ? { exportSettings } : {};
14369
14548
  }
@@ -15607,6 +15786,60 @@ var grida;
15607
15786
  })(grida || (grida = {}));
15608
15787
  var cloneWithUndefinedValues = (obj) => Object.fromEntries(Object.keys(obj).map((key) => [key, void 0]));
15609
15788
 
15789
+ // figma-default-fonts.ts
15790
+ var FIGMA_DEFAULT_FONT_ENTRIES = [
15791
+ {
15792
+ family: "Inter",
15793
+ url: "https://fonts.gstatic.com/s/inter/v19/UcCo3FwrK3iLTfvlaQc78lA2.ttf"
15794
+ },
15795
+ {
15796
+ family: "Noto Sans KR",
15797
+ url: "https://fonts.gstatic.com/s/notosanskr/v37/PbykFmXiEBPT4ITbgNA5Cgm21nTs4JMMuA.ttf"
15798
+ },
15799
+ {
15800
+ family: "Noto Sans JP",
15801
+ url: "https://fonts.gstatic.com/s/notosansjp/v54/-F62fjtqLzI2JPCgQBnw7HFoxgIO2lZ9hg.ttf"
15802
+ },
15803
+ {
15804
+ family: "Noto Sans SC",
15805
+ url: "https://fonts.gstatic.com/s/notosanssc/v38/k3kXo84MPvpLmixcA63oeALhKYiJ-Q7m8w.ttf"
15806
+ },
15807
+ {
15808
+ family: "Noto Sans TC",
15809
+ url: "https://fonts.gstatic.com/s/notosanstc/v37/-nF7OG829Oofr2wohFbTp9iFPysLA_ZJ1g.ttf"
15810
+ },
15811
+ {
15812
+ family: "Noto Sans HK",
15813
+ url: "https://fonts.gstatic.com/s/notosanshk/v33/nKKQ-GM_FYFRJvXzVXaAPe9hNHB3Eu7mOQ.ttf"
15814
+ },
15815
+ {
15816
+ family: "Noto Color Emoji",
15817
+ url: "https://fonts.gstatic.com/s/notocoloremoji/v35/Yq6P-KqIXTD0t4D9z1ESnKM3-HpFab5s79iz64w.ttf"
15818
+ }
15819
+ ];
15820
+ var FIGMA_DEFAULT_FALLBACK_ORDER = [
15821
+ "Inter",
15822
+ "Noto Sans KR",
15823
+ "Noto Sans JP",
15824
+ "Noto Sans SC",
15825
+ "Noto Sans TC",
15826
+ "Noto Sans HK",
15827
+ "Noto Color Emoji"
15828
+ ];
15829
+ async function ensureFigmaDefaultFonts(canvas) {
15830
+ for (const entry of FIGMA_DEFAULT_FONT_ENTRIES) {
15831
+ const res = await fetch(entry.url);
15832
+ if (!res.ok) {
15833
+ throw new Error(
15834
+ `Figma default font fetch failed: ${entry.family} ${res.status} ${res.statusText}`
15835
+ );
15836
+ }
15837
+ const buffer = await res.arrayBuffer();
15838
+ canvas.addFont(entry.family, new Uint8Array(buffer));
15839
+ }
15840
+ canvas.setFallbackFonts(FIGMA_DEFAULT_FALLBACK_ORDER);
15841
+ }
15842
+
15610
15843
  // lib.ts
15611
15844
  var FigmaDocument = class {
15612
15845
  /**
@@ -15708,7 +15941,8 @@ function exportSettingToRenderOptions(node, setting) {
15708
15941
  JPG: "jpeg",
15709
15942
  PNG: "png",
15710
15943
  SVG: "svg",
15711
- PDF: "pdf"
15944
+ PDF: "pdf",
15945
+ WEBP: "webp"
15712
15946
  };
15713
15947
  const format = formatMap[setting.format] ?? "png";
15714
15948
  const constraint = setting.constraint;
@@ -15793,6 +16027,7 @@ function restJsonToSceneJson(json, rootNodeId, images) {
15793
16027
  const resolveImageSrc = images && (Object.keys(images).length > 0 ? (ref) => ref in images ? `res://images/${ref}` : null : void 0);
15794
16028
  const buildContext = (overrides) => ({
15795
16029
  gradient_id_generator: baseGradientGen,
16030
+ prefer_path_for_geometry: true,
15796
16031
  ...resolveImageSrc && { resolve_image_src: resolveImageSrc },
15797
16032
  ...overrides
15798
16033
  });
@@ -15931,6 +16166,7 @@ function figBytesToSceneJson(figBytes, rootNodeId) {
15931
16166
  node_id_generator: () => `refig-${++counter}`,
15932
16167
  gradient_id_generator: () => `grad-${++counter}`,
15933
16168
  preserve_figma_ids: true,
16169
+ prefer_path_for_geometry: true,
15934
16170
  ...resolveImageSrc && { resolve_image_src: resolveImageSrc }
15935
16171
  };
15936
16172
  const { document: packed } = iofigma.kiwi.convertPageToScene(page, context);
@@ -15960,6 +16196,11 @@ var FigmaRenderer = class {
15960
16196
  height,
15961
16197
  useEmbeddedFonts: this.options.useEmbeddedFonts ?? true
15962
16198
  });
16199
+ if (this.options.loadFigmaDefaultFonts !== false) {
16200
+ await ensureFigmaDefaultFonts(
16201
+ this._canvas
16202
+ );
16203
+ }
15963
16204
  return this._canvas;
15964
16205
  }
15965
16206
  loadScene(canvas, nodeId) {
package/dist/cli.mjs CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  exportSettingToRenderOptions,
7
7
  figFileToRestLikeDocument,
8
8
  iofigma
9
- } from "./chunk-DAHUXARL.mjs";
9
+ } from "./chunk-YI7PIULZ.mjs";
10
10
 
11
11
  // cli.ts
12
12
  import {
@@ -31,6 +31,7 @@ var EXT_BY_FORMAT = {
31
31
  png: "png",
32
32
  jpeg: "jpeg",
33
33
  jpg: "jpeg",
34
+ webp: "webp",
34
35
  svg: "svg",
35
36
  pdf: "pdf"
36
37
  };
@@ -80,7 +81,7 @@ function exportAllOutputBasename(nodeId, suffix, format) {
80
81
  const name = safeSuffix ? `${safeId}_${safeSuffix}` : safeId;
81
82
  return `${name}.${ext}`;
82
83
  }
83
- async function runExportAll(documentPath, outDir, imagesDir) {
84
+ async function runExportAll(documentPath, outDir, imagesDir, skipDefaultFonts) {
84
85
  const isFig = documentPath.toLowerCase().endsWith(".fig");
85
86
  let document;
86
87
  let items;
@@ -109,6 +110,9 @@ async function runExportAll(documentPath, outDir, imagesDir) {
109
110
  rendererOptions = { images: readImagesFromDir(imagesDir) };
110
111
  }
111
112
  }
113
+ if (skipDefaultFonts || process.env.REFIG_SKIP_DEFAULT_FONTS === "1") {
114
+ rendererOptions = { ...rendererOptions, loadFigmaDefaultFonts: false };
115
+ }
112
116
  if (items.length === 0) {
113
117
  process.stdout.write("No nodes with export settings found.\n");
114
118
  return;
@@ -144,6 +148,9 @@ async function runSingleNode(documentPath, nodeId, outPath, opts) {
144
148
  const isJson = documentPath.toLowerCase().endsWith(".json");
145
149
  const document = isJson ? new FigmaDocument(JSON.parse(readFileSync(documentPath, "utf8"))) : new FigmaDocument(new Uint8Array(readFileSync(documentPath)));
146
150
  const rendererOptions = isJson && opts.imagesDir ? { images: readImagesFromDir(opts.imagesDir) } : {};
151
+ if (opts.skipDefaultFonts || process.env.REFIG_SKIP_DEFAULT_FONTS === "1") {
152
+ rendererOptions.loadFigmaDefaultFonts = false;
153
+ }
147
154
  const renderer = new FigmaRenderer(document, rendererOptions);
148
155
  try {
149
156
  const result = await renderer.render(nodeId, {
@@ -183,7 +190,10 @@ async function main() {
183
190
  ).option(
184
191
  "--format <fmt>",
185
192
  "png | jpeg | webp | pdf | svg (single-node only; default: from --out extension)"
186
- ).option("--width <px>", "Viewport width (single-node only)", "1024").option("--height <px>", "Viewport height (single-node only)", "1024").option("--scale <n>", "Raster scale factor (single-node only)", "1").action(
193
+ ).option("--width <px>", "Viewport width (single-node only)", "1024").option("--height <px>", "Viewport height (single-node only)", "1024").option("--scale <n>", "Raster scale factor (single-node only)", "1").option(
194
+ "--skip-default-fonts",
195
+ "Do not load Figma default fonts (same as REFIG_SKIP_DEFAULT_FONTS=1)"
196
+ ).action(
187
197
  async (input, options) => {
188
198
  const outPath = String(options.out ?? "").trim();
189
199
  const exportAll = options.exportAll === true;
@@ -211,7 +221,12 @@ async function main() {
211
221
  } else {
212
222
  mkdirSync(outDir, { recursive: true });
213
223
  }
214
- await runExportAll(documentPath, outDir, imagesDir);
224
+ await runExportAll(
225
+ documentPath,
226
+ outDir,
227
+ imagesDir,
228
+ options.skipDefaultFonts === true
229
+ );
215
230
  return;
216
231
  }
217
232
  if (!nodeId) {
@@ -225,7 +240,8 @@ async function main() {
225
240
  width,
226
241
  height,
227
242
  scale,
228
- imagesDir
243
+ imagesDir,
244
+ skipDefaultFonts: options.skipDefaultFonts === true
229
245
  });
230
246
  }
231
247
  );
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  collectExportsFromDocument,
5
5
  exportSettingToRenderOptions,
6
6
  resolveMimeType
7
- } from "./chunk-DAHUXARL.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.0",
3
+ "version": "0.0.2",
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": [
@@ -21,7 +21,7 @@
21
21
  "wasm",
22
22
  "webp"
23
23
  ],
24
- "homepage": "https://github.com/gridaco/grida/tree/main/packages/grida-canvas-sdk-render-figma",
24
+ "homepage": "https://grida.co/docs/packages/@grida/refig",
25
25
  "bugs": "https://github.com/gridaco/grida/issues",
26
26
  "license": "MIT",
27
27
  "author": "softmarshmallow",
@@ -55,11 +55,11 @@
55
55
  "access": "public"
56
56
  },
57
57
  "dependencies": {
58
- "@grida/canvas-wasm": "0.90.0-canary.6",
58
+ "@grida/canvas-wasm": "0.90.0-canary.8",
59
59
  "commander": "^12.1.0"
60
60
  },
61
61
  "devDependencies": {
62
- "@figma/rest-api-spec": "0.35.0",
62
+ "@figma/rest-api-spec": "0.36.0",
63
63
  "fflate": "^0.8.2",
64
64
  "tsup": "^8.5.0"
65
65
  },