@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 +63 -15
- package/dist/browser.d.mts +8 -0
- package/dist/browser.mjs +1 -1
- package/dist/{chunk-DAHUXARL.mjs → chunk-YI7PIULZ.mjs} +258 -17
- package/dist/cli.mjs +21 -5
- package/dist/index.mjs +1 -1
- package/package.json +4 -4
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/
|
|
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**
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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.
|
|
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
|
|
package/dist/browser.d.mts
CHANGED
|
@@ -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
|
@@ -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
|
|
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,
|
|
13715
|
-
[0, 1,
|
|
13778
|
+
[1, 0, 0],
|
|
13779
|
+
[0, 1, 0]
|
|
13716
13780
|
],
|
|
13717
|
-
size: { x:
|
|
13781
|
+
size: { x: width, y: height }
|
|
13718
13782
|
}),
|
|
13719
|
-
...
|
|
13720
|
-
|
|
13721
|
-
|
|
13722
|
-
|
|
13723
|
-
|
|
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:
|
|
13729
|
-
layout_target_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 =
|
|
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 =
|
|
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 {
|
|
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-
|
|
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").
|
|
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(
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grida/refig",
|
|
3
|
-
"version": "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://
|
|
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.
|
|
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.
|
|
62
|
+
"@figma/rest-api-spec": "0.36.0",
|
|
63
63
|
"fflate": "^0.8.2",
|
|
64
64
|
"tsup": "^8.5.0"
|
|
65
65
|
},
|