@grida/refig 0.0.2 → 0.0.4

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
@@ -6,6 +6,8 @@ Render Figma documents to **PNG, JPEG, WebP, PDF, and SVG** in **Node.js (no bro
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
8
 
9
+ Refig aims to render designs as faithfully as possible to the original. See [Known limitations](#known-limitations) for current exceptions.
10
+
9
11
  ## Features (checklist)
10
12
 
11
13
  - [x] Render from **`.fig` files** (offline / no API calls)
@@ -14,6 +16,7 @@ Use a `.fig` export (offline) or a Figma REST API file JSON response (`GET /v1/f
14
16
  - [x] **CLI** (`refig`) and **library API** (`FigmaDocument`, `FigmaRenderer`)
15
17
  - [x] **Node.js** + **browser** entrypoints (`@grida/refig`, `@grida/refig/browser`)
16
18
  - [x] IMAGE fills supported via **embedded `.fig` images** or a **local `images/` directory** for REST JSON
19
+ - [x] **Bring-your-own-font** — supply custom font files for designs that use non-default typefaces
17
20
  - [x] Batch export with **`--export-all`** (renders nodes with Figma export presets)
18
21
  - [x] WASM + Skia-backed renderer via `@grida/canvas-wasm`
19
22
 
@@ -105,6 +108,57 @@ const { data } = await renderer.render("<node-id>", { format: "png" });
105
108
  renderer.dispose();
106
109
  ```
107
110
 
111
+ ### Render with custom fonts
112
+
113
+ Unlike images, fonts do not have a Figma API. Use **`listFontFamilies()`** to see which font families are used in your file (or a scoped node), then load those fonts and pass them to the renderer:
114
+
115
+ ```ts
116
+ import { readFileSync, writeFileSync } from "node:fs";
117
+ import { FigmaDocument, FigmaRenderer } from "@grida/refig";
118
+
119
+ const doc = FigmaDocument.fromFile("path/to/file.fig");
120
+
121
+ // 1. Discover font families used (omit rootNodeId for full document)
122
+ const fontFamilies = doc.listFontFamilies("<node-id>"); // e.g. ["Inter", "Caveat", "Roboto"]
123
+
124
+ // 2. Load your custom fonts (local FS, CDN, asset service, etc.)
125
+ // Skip Figma defaults (Inter, Noto Sans KR/JP/SC, etc.) — the renderer loads those.
126
+ const fonts: Record<string, Uint8Array> = {};
127
+ for (const family of fontFamilies) {
128
+ if (
129
+ family === "Inter" ||
130
+ family.startsWith("Noto Sans") ||
131
+ family === "Noto Color Emoji"
132
+ )
133
+ continue;
134
+ fonts[family] = new Uint8Array(readFileSync(`./fonts/${family}.ttf`)); // adjust path to your font file structure
135
+ }
136
+
137
+ // 3. Render
138
+ const renderer = new FigmaRenderer(doc, { fonts });
139
+ const { data } = await renderer.render("<node-id>", { format: "png" });
140
+ writeFileSync("out.png", data);
141
+ renderer.dispose();
142
+ ```
143
+
144
+ **CLI:** Use `--fonts <dir>` to pass a directory of TTF/OTF files (scanned recursively). Fonts are inferred from the name table; multiple files per family are grouped automatically. Use `--skip-default-fonts` to avoid loading Figma defaults (useful to verify custom font rendering):
145
+
146
+ ```sh
147
+ refig ./figma-response.json --fonts ./my-fonts --node "1:23" --out out.png
148
+ refig ./doc.json --fonts ./my-fonts --node "1:23" --out out.png --skip-default-fonts
149
+ ```
150
+
151
+ With a project directory, place fonts in `fonts/` next to `document.json` (and optionally `images/`); refig auto-discovers them:
152
+
153
+ ```sh
154
+ refig ./my-figma-export --node "1:23" --format png
155
+ # Expects my-figma-export/document.json and, if present, my-figma-export/fonts/
156
+ ```
157
+
158
+ Load **all** font files that match each family (variable or static) so the renderer can pick the right one for each text style, just like the original design. For multiple files per family (e.g. Regular, Bold, Italic), pass an array: `fonts: { "MyFamily": [regularBytes, boldBytes, italicBytes] }`.
159
+
160
+ If the design uses **locally-installed fonts** (fonts the designer had on their machine), loading those from your OS may require extra scripts or tooling to locate and extract the font files. We do not provide such tooling.
161
+
108
162
  ## Quick start (Browser)
109
163
 
110
164
  ```ts
@@ -143,14 +197,21 @@ new FigmaDocument(json: Record<string, unknown>)
143
197
  // From a file path (Node only — @grida/refig)
144
198
  FigmaDocument.fromFile("path/to/file.fig") // .fig binary
145
199
  FigmaDocument.fromFile("path/to/doc.json") // REST API JSON
200
+
201
+ // Font families used in the document (for bring-your-own-font)
202
+ document.listFontFamilies(rootNodeId?: string): string[]
203
+ // — rootNodeId: optional; scope to that node's subtree, or omit for full document
204
+ // — returns unique family names; load all font files that match each family (VF or static)
146
205
  ```
147
206
 
148
207
  ### `FigmaRenderer`
149
208
 
150
209
  ```ts
151
210
  const renderer = new FigmaRenderer(document: FigmaDocument, options?: {
152
- useEmbeddedFonts?: boolean; // default: true
211
+ useEmbeddedFonts?: boolean; // default: true
212
+ loadFigmaDefaultFonts?: boolean; // default: true — Inter, Noto Sans KR/JP/SC, etc.
153
213
  images?: Record<string, Uint8Array>; // image ref → bytes; used for REST API IMAGE fills
214
+ fonts?: Record<string, Uint8Array | Uint8Array[]>; // font family → bytes (TTF/OTF); one or more files per family
154
215
  });
155
216
 
156
217
  const result = await renderer.render(nodeId: string, {
@@ -194,8 +255,9 @@ pnpm add -g @grida/refig
194
255
  Or run without installing:
195
256
 
196
257
  ```sh
197
- npx @grida/refig <input> --node <node-id> --out <path>
198
- pnpm dlx @grida/refig <input> --node <node-id> --out <path>
258
+ # Instant usage (writes to OS temp dir; output path printed)
259
+ npx @grida/refig <input> --node <node-id> --format png
260
+ pnpm dlx @grida/refig <input> --export-all
199
261
  ```
200
262
 
201
263
  ### Usage
@@ -205,50 +267,61 @@ pnpm dlx @grida/refig <input> --node <node-id> --out <path>
205
267
  - A **file**: path to a `.fig` file or a JSON file (Figma REST API response).
206
268
  - A **directory**: path to a folder that contains:
207
269
  - **`document.json`** — the REST API response (required),
208
- - **`images/`** — directory of image assets (optional; used for REST API IMAGE fills).
270
+ - **`images/`** — directory of image assets (optional; used for REST API IMAGE fills),
271
+ - **`fonts/`** — directory of font files TTF/OTF (optional; scanned recursively).
209
272
 
210
- Using a directory avoids passing the document and images separately.
273
+ Using a directory avoids passing the document, images, and fonts separately.
211
274
 
212
275
  ```sh
213
276
  # Single node (default)
214
- refig <input> --node <node-id> --out <path> [options]
277
+ # - Without --out: writes to OS temp dir (requires --format)
278
+ # - With --out: format inferred from file extension unless --format is provided
279
+ refig <input> --node <node-id> --format <fmt> [options]
280
+ refig <input> --node <node-id> --out <path> [--format <fmt>] [options]
215
281
 
216
282
  # With images directory (REST JSON only; IMAGE fills rendered from local files)
217
- refig <input> --images <dir> --node <node-id> --out <path>
283
+ refig <input> --images <dir> --node <node-id> --format <fmt> [options]
284
+ refig <input> --images <dir> --node <node-id> --out <path> [--format <fmt>] [options]
218
285
 
219
286
  # Directory input: document.json + images/ under one folder
220
- refig ./my-figma-export --node "1:23" --out ./out.png
287
+ refig ./my-figma-export --node "1:23" --format png
221
288
 
222
289
  # Export all nodes that have exportSettings (REST JSON or .fig)
223
- refig <input> --export-all --out <output-dir>
290
+ refig <input> --export-all [--out <output-dir>]
224
291
  ```
225
292
 
226
293
  ### Examples
227
294
 
228
295
  ```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
296
+ # Instant usage: omit --out to write to OS temp directory (output path printed)
297
+ refig ./design.fig --node "1:23" --format png
298
+ refig ./figma-response.json --node "1:23" --format svg
234
299
 
235
300
  # Directory with document.json (and optionally images/): one path instead of response + --images
236
- refig ./my-figma-export --node "1:23" --out ./out.png
301
+ refig ./my-figma-export --node "1:23" --format png
237
302
  # (my-figma-export/document.json, my-figma-export/images/)
238
303
 
239
304
  # Explicit images directory (when not using a project directory)
240
- refig ./figma-response.json --images ./downloaded-images --node "1:23" --out ./out.png
305
+ refig ./figma-response.json --images ./downloaded-images --node "1:23" --format png
306
+
307
+ # Custom fonts (when design uses non-default typefaces)
308
+ refig ./figma-response.json --fonts ./my-fonts --node "1:23" --out out.png
309
+ refig ./doc.json --fonts ./fonts --node "1:23" --out out.png --skip-default-fonts
241
310
 
242
311
  # 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
312
+ refig ./figma-response.json --export-all
313
+ refig ./design.fig --export-all
245
314
 
246
315
  # Scale 2x, custom dimensions
247
- refig ./design.fig --node "1:23" --out ./out.png --width 512 --height 512 --scale 2
316
+ refig ./design.fig --node "1:23" --format png --width 512 --height 512 --scale 2
317
+
318
+ # Deterministic output: provide --out (useful for CI or saving into a known path)
319
+ refig ./design.fig --node "1:23" --out ./out.png
320
+ refig ./figma-response.json --export-all --out ./exports
248
321
 
249
322
  # 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
323
+ npx @grida/refig ./design.fig --node "1:23" --format png
324
+ pnpm dlx @grida/refig ./design.fig --export-all
252
325
  ```
253
326
 
254
327
  ### Quick test via `figma_archive.py` (REST API → `document.json` + `images/`)
@@ -275,10 +348,10 @@ This writes:
275
348
 
276
349
  ```sh
277
350
  # Single node
278
- refig ./my-figma-export --node "1:23" --out ./out.png
351
+ refig ./my-figma-export --node "1:23" --format png
279
352
 
280
353
  # Or export everything with Figma export presets
281
- refig ./my-figma-export --export-all --out ./exports
354
+ refig ./my-figma-export --export-all
282
355
  ```
283
356
 
284
357
  ### Export all (`--export-all`)
@@ -291,17 +364,19 @@ With **`--export-all`**, refig walks the document and renders every node that ha
291
364
 
292
365
  ### Flags
293
366
 
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) |
367
+ | Flag | Required | Default | Description |
368
+ | ---------------------- | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
369
+ | `<input>` | yes | | Path to `.fig`, JSON file, or directory containing `document.json` (and optionally `images/`, `fonts/`) |
370
+ | `--images <dir>` | no | | Directory of image assets for REST document (ignored if `<input>` is a dir with `images/`) |
371
+ | `--fonts <dir>` | no | | Directory of font files (TTF/OTF) for custom fonts (ignored if `<input>` is a dir with `fonts/`) |
372
+ | `--node <id>` | yes\* | | Figma node ID to render (\*omit when using `--export-all`) |
373
+ | `--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`). |
374
+ | `--export-all` | no | | Export every node with exportSettings (REST JSON or .fig); `--out` is a directory |
375
+ | `--format <fmt>` | no | inferred from `--out` extension | `png`, `jpeg`, `webp`, `pdf`, `svg` (single-node only; required when `--out` is omitted) |
376
+ | `--width <px>` | no | `1024` | Viewport width (single-node only) |
377
+ | `--height <px>` | no | `1024` | Viewport height (single-node only) |
378
+ | `--scale <n>` | no | `1` | Raster scale factor (single-node only) |
379
+ | `--skip-default-fonts` | no | | Do not load Figma default fonts (Inter, Noto Sans, etc.); use only custom fonts from `--fonts` or `fonts/` |
305
380
 
306
381
  ## Architecture
307
382
 
@@ -332,13 +407,19 @@ REST JSON ───┘
332
407
  **CLI** — You can pass images in two ways:
333
408
 
334
409
  - **`--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`
336
- - **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`
338
- (expects `my-figma-export/document.json` and, if present, `my-figma-export/images/`.)
410
+ `refig ./figma-response.json --images ./downloaded-images --node "1:23" --format png`
411
+ - **Directory input** — Pass a single directory that contains **`document.json`** (REST response) and optionally **`images/`** and **`fonts/`**. No need to pass `--images` or `--fonts` separately:
412
+ `refig ./my-figma-export --node "1:23" --format png`
413
+ (expects `my-figma-export/document.json` and, if present, `my-figma-export/images/` and `my-figma-export/fonts/`.)
339
414
 
340
415
  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.
341
416
 
417
+ ## Known limitations
418
+
419
+ - **Rich text** — Text with mixed styles (e.g. bold and italic in the same paragraph) is not yet supported.
420
+ - **Image transformation** — Complex image transforms from Figma designs are not yet properly aligned. Known issue; will fix.
421
+ - **Emoji** — Rendered with Noto Color Emoji instead of Figma's platform emoji (Apple Color Emoji / Segoe UI Emoji). Output differs by design.
422
+
342
423
  ## Not planned
343
424
 
344
425
  - **Figma API fetching / auth** — bring your own tokens and HTTP client
@@ -364,7 +445,9 @@ Yes. Import from `@grida/refig/browser`. The core renderer uses `@grida/canvas-w
364
445
 
365
446
  ### What about fonts?
366
447
 
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.
448
+ 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).
449
+
450
+ **Custom fonts** (e.g. Caveat, Roboto, brand typefaces) use the bring-your-own-font flow: call **`document.listFontFamilies(rootNodeId?)`** to see which families are used, load those fonts yourself, then pass **`fonts: Record<string, Uint8Array>`** to `FigmaRenderer`. See [Render with custom fonts](#render-with-custom-fonts).
368
451
 
369
452
  ## Contributing
370
453
 
@@ -372,7 +455,7 @@ From the package root:
372
455
 
373
456
  1. Install dependencies and build: `pnpm install && pnpm build`
374
457
  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`)
458
+ 3. Run the `refig` command from anywhere to test (e.g. `refig ./fixture.json --node "1:1" --format png`)
376
459
 
377
460
  To unlink: `pnpm unlink --global`.
378
461
 
@@ -1,4 +1,5 @@
1
1
  import { ExportSetting } from '@figma/rest-api-spec';
2
+ import { iofigma } from '@grida/io-figma';
2
3
 
3
4
  /**
4
5
  * @grida/refig — shared core (no node:fs, no DOM)
@@ -28,6 +29,14 @@ interface RefigRendererOptions {
28
29
  * Ref must match document references (e.g. 40-char hex for .fig, Figma image fill hash for REST).
29
30
  */
30
31
  images?: Record<string, Uint8Array>;
32
+ /**
33
+ * Custom fonts keyed by family name. Use the family name returned by
34
+ * `listFontFamilies()` (e.g. from Figma's `style.font_family`). Pass one or
35
+ * more font files per family; the canvas resolves the correct face per
36
+ * text style. Skip Figma defaults (Inter, Noto Sans KR/JP/SC, etc.) —
37
+ * those are loaded by `loadFigmaDefaultFonts`.
38
+ */
39
+ fonts?: Record<string, Uint8Array | Uint8Array[]>;
31
40
  }
32
41
  interface RefigRenderOptions {
33
42
  format: RefigRenderFormat;
@@ -44,21 +53,61 @@ interface RefigRenderResult {
44
53
  height: number;
45
54
  }
46
55
  type FigmaJsonDocument = Record<string, unknown>;
56
+ type FigFileDocument = ReturnType<typeof iofigma.kiwi.parseFile>;
47
57
  declare class FigmaDocument {
48
58
  readonly sourceType: "fig-file" | "rest-api-json";
49
59
  /**
50
- * For "fig-file" this is the raw bytes of the .fig file.
60
+ * For "fig-file" this is the raw bytes (Uint8Array) or a pre-parsed FigFileDocument
61
+ * (e.g. from parseFileFromStream for large ZIP files).
51
62
  * For "rest-api-json" this is the parsed REST API document JSON.
52
63
  */
53
- readonly payload: Uint8Array | FigmaJsonDocument;
64
+ readonly payload: Uint8Array | FigmaJsonDocument | FigFileDocument;
65
+ /** Cached parsed FigFile (.fig only). */
66
+ private _figFile?;
67
+ /**
68
+ * Cache of ResolvedScene per rootNodeId. REST with images is not cached.
69
+ * Bounded to avoid unbounded memory growth in --export-all flows.
70
+ */
71
+ private _sceneCache;
72
+ /** Max cached scenes; evicts LRU when exceeded. */
73
+ private static readonly _MAX_SCENE_CACHE;
54
74
  /**
55
- * @param input Raw `.fig` bytes (Uint8Array) or a parsed Figma REST API
56
- * document JSON object.
75
+ * @param input Raw `.fig` bytes (Uint8Array), a pre-parsed FigFileDocument
76
+ * (e.g. from parseFileFromStream for large files), or REST JSON.
57
77
  *
58
78
  * For file-path convenience in Node, use `FigmaDocument.fromFile()` from
59
79
  * the `@grida/refig` entrypoint.
60
80
  */
61
- constructor(input: Uint8Array | FigmaJsonDocument);
81
+ constructor(input: Uint8Array | FigmaJsonDocument | FigFileDocument);
82
+ /**
83
+ * Resolve document to Grida IR (scene JSON + images to register).
84
+ * On-demand, cached when deterministic (no images for REST).
85
+ * Cache is bounded (LRU eviction) to avoid OOM in --export-all flows.
86
+ * @internal
87
+ */
88
+ _resolve(rootNodeId?: string, images?: Record<string, Uint8Array>): ResolvedScene;
89
+ private _sceneCacheGet;
90
+ private _sceneCacheSet;
91
+ /** @internal */
92
+ private _restToScene;
93
+ /** @internal */
94
+ private _figToScene;
95
+ /**
96
+ * Returns the list of font family names used in this document.
97
+ *
98
+ * The result is family names only — no weights, PostScript names, or other metadata.
99
+ * That is intentional: in practice, you can load all TTF/OTF files for each family
100
+ * (variable or static) and pass them to the renderer; it will resolve the correct face
101
+ * per text style. We prioritize simple usage and accurate font selection over
102
+ * performance or resource-optimized patterns.
103
+ *
104
+ * When rootNodeId is omitted, traverses all pages so fonts from every page are included.
105
+ *
106
+ * @param rootNodeId — Optional. When provided, scope to that node's subtree. Omit for the full document (all pages).
107
+ * @returns Unique font family names (e.g. `["Inter", "Caveat", "Roboto"]`).
108
+ */
109
+ listFontFamilies(rootNodeId?: string): string[];
110
+ private _collectFontFamiliesFromSceneJson;
62
111
  }
63
112
  declare function resolveMimeType(format: RefigRenderFormat): string;
64
113
  type RestNode = Record<string, unknown> & {
@@ -81,6 +130,15 @@ declare function collectExportsFromDocument(json: FigmaJsonDocument): ExportItem
81
130
  * Constraint SCALE → scale; WIDTH/HEIGHT → width/height from constraint value and node aspect ratio.
82
131
  */
83
132
  declare function exportSettingToRenderOptions(node: RestNode, setting: ExportSetting): RefigRenderOptions;
133
+ /**
134
+ * Resolved scene: Grida IR ready for canvas load.
135
+ * @internal
136
+ */
137
+ interface ResolvedScene {
138
+ sceneJson: string;
139
+ images: Record<string, Uint8Array>;
140
+ imageRefsUsed?: string[];
141
+ }
84
142
  declare class FigmaRenderer {
85
143
  readonly document: FigmaDocument;
86
144
  readonly options: RefigRendererOptions;
package/dist/browser.mjs CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  collectExportsFromDocument,
5
5
  exportSettingToRenderOptions,
6
6
  resolveMimeType
7
- } from "./chunk-YI7PIULZ.mjs";
7
+ } from "./chunk-INJ5F2RK.mjs";
8
8
  export {
9
9
  FigmaDocument,
10
10
  FigmaRenderer,