Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
b7f102c
Update README.md to clarify input sources for Figma rendering
softmarshmallow Feb 15, 2026
82f42cf
repo: ts, tsx, turbo
softmarshmallow Feb 15, 2026
e9780bd
Implement headless Figma rendering with @grida/refig package
softmarshmallow Feb 15, 2026
c9ce1a1
Add Figma archiving tool and update .gitignore
softmarshmallow Feb 15, 2026
51e9bc3
doc: refig images input model
softmarshmallow Feb 15, 2026
cbba6a3
Implement image registration with custom RID in Renderer API
softmarshmallow Feb 16, 2026
cb17860
wasm 0.90.0-canary.6
softmarshmallow Feb 16, 2026
06fa6eb
Refactor image handling in Editor and Playground components
softmarshmallow Feb 16, 2026
18c9765
Enhance image handling in Figma rendering
softmarshmallow Feb 16, 2026
e320d5a
fallback COMPONENT_SET to FRAME
softmarshmallow Feb 16, 2026
efdf495
add figma rest api community file fixture
softmarshmallow Feb 16, 2026
3e46928
update with fixture tests
softmarshmallow Feb 16, 2026
3af0e12
shebang config
softmarshmallow Feb 16, 2026
735c839
Update dependencies and add smoke tests for refig CLI
softmarshmallow Feb 16, 2026
4bbd9b3
Update fixture fig (784448220678228461) file with export settings inc…
softmarshmallow Feb 16, 2026
6f2a429
Enhance Figma rendering capabilities
softmarshmallow Feb 16, 2026
c1d7c46
Add prepack and postpack scripts for npm publishing
softmarshmallow Feb 16, 2026
e230602
Enhance Figma archiving and documentation
softmarshmallow Feb 16, 2026
4b25a59
update fixtures
softmarshmallow Feb 16, 2026
a9b2f2a
wasm 0.90.0-canary.7
softmarshmallow Feb 17, 2026
ba0e8a8
Enhance Figma font handling and update dependencies
softmarshmallow Feb 17, 2026
c88728d
optimize test
softmarshmallow Feb 17, 2026
faa985a
refig cli: --skip-default-fonts
softmarshmallow Feb 17, 2026
ece6188
bump @figma/rest-api-spec
softmarshmallow Feb 17, 2026
b3294b9
Add support for volatile vectorNetwork in Figma REST API
softmarshmallow Feb 17, 2026
8b0a291
docs: refig
softmarshmallow Feb 17, 2026
a2d304f
refig 0.0.1
softmarshmallow Feb 17, 2026
3719cd6
chore
softmarshmallow Feb 17, 2026
4b1804e
chore
softmarshmallow Feb 17, 2026
cffdac8
wasm 0.90.0-canary.8
softmarshmallow Feb 17, 2026
69762bd
add path node support and have refig prefer it.
softmarshmallow Feb 17, 2026
29ea801
docs: add title
softmarshmallow Feb 17, 2026
a13257f
fix crates build
softmarshmallow Feb 17, 2026
a53566c
refig 0.0.2
softmarshmallow Feb 17, 2026
11f17a3
Update refig to version 0.0.3 with enhanced output handling
softmarshmallow Feb 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -205,4 +205,10 @@ node-compile-cache/
/target

# Test artifacts
**/__tests__/artifacts/
**/__tests__/artifacts/

### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
7 changes: 4 additions & 3 deletions .tools/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ This directory contains miscellaneous, trivial scripts that are useful for inter

## Available Tools

| Tool | Description | Usage |
| -------------- | --------------------------------------------- | -------------------- |
| `pbdump.swift` | Dump macOS clipboard contents (all UTI types) | `swift pbdump.swift` |
| Tool | Description | Usage |
| ------------------ | ----------------------------------------------------------- | -------------------------------------------------------------------- |
| `pbdump.swift` | Dump macOS clipboard contents (all UTI types) | `swift pbdump.swift` |
| `figma_archive.py` | Archive a Figma file via REST API (document.json + images/) | `python .tools/figma_archive.py --filekey <key> --archive-dir <dir>` |

## Contributing

Expand Down
157 changes: 157 additions & 0 deletions .tools/figma_archive.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
# https://gist.github.com/softmarshmallow/27ad65dfa5babc2c67b41740f1f05791
"""
Archive a Figma file via REST API: document.json (with geometry) and images/*.

Stdlib only. Usage:

python .tools/figma_archive.py --filekey <key> --archive-dir <dir> [--x-figma-token <token>]
# or set FIGMA_TOKEN in the environment

Output layout:
<archive-dir>/document.json — GET /v1/files/:key?geometry=paths
<archive-dir>/images/<ref>.<ext> — from GET /v1/files/:key/images, then download each URL
"""

import argparse
import json
import os
import sys
from pathlib import Path
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen

FIGMA_BASE = "https://api.figma.com"
ENV_TOKEN = "FIGMA_TOKEN"

# Content-Type -> file extension for image fills
MIME_EXT = {
"image/png": "png",
"image/jpeg": "jpeg",
"image/jpg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
}


def get_token(flag_value: str | None) -> str:
token = flag_value or os.environ.get(ENV_TOKEN)
if not token or not token.strip():
print(f"error: provide --x-figma-token or set {ENV_TOKEN}", file=sys.stderr)
sys.exit(1)
return token.strip()
Comment on lines +37 to +42
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

str | None union syntax requires Python ≥ 3.10.

If this script needs to run on older Python 3 versions (e.g., 3.8/3.9), use Optional[str] from typing instead or add from __future__ import annotations.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.tools/figma_archive.py around lines 37 - 42, The function get_token uses
Python 3.10 union syntax "str | None" which breaks on older Python 3.x; change
the annotation to from typing import Optional and use Optional[str] (or
alternatively add "from __future__ import annotations" at the top) and ensure
typing import is added; update the get_token signature and any other annotations
using "|" (e.g., get_token(flag_value: Optional[str]) -> str) and keep ENV_TOKEN
and error handling unchanged.



def api_get(url: str, token: str) -> bytes:
req = Request(url, method="GET", headers={"X-Figma-Token": token})
try:
with urlopen(req, timeout=60) as resp:
return resp.read()
except HTTPError as e:
print(f"error: HTTP {e.code} {e.reason}: {url}", file=sys.stderr)
if e.fp:
body = e.fp.read().decode("utf-8", errors="replace")[:500]
print(body, file=sys.stderr)
sys.exit(1)
except URLError as e:
print(f"error: request failed: {e.reason}", file=sys.stderr)
sys.exit(1)


def fetch_document(file_key: str, token: str) -> dict:
url = f"{FIGMA_BASE}/v1/files/{file_key}?geometry=paths"
raw = api_get(url, token)
return json.loads(raw.decode("utf-8"))


def fetch_image_fills(file_key: str, token: str) -> dict[str, str]:
url = f"{FIGMA_BASE}/v1/files/{file_key}/images"
raw = api_get(url, token)
data = json.loads(raw.decode("utf-8"))
meta = data.get("meta") or {}
images = meta.get("images") or {}
return {k: v for k, v in images.items() if v}


def download_image(url: str) -> tuple[bytes, str]:
# Image URLs from the Images API are pre-signed; no token needed.
req = Request(url, method="GET")
with urlopen(req, timeout=120) as resp:
data = resp.read()
content_type = (
(resp.headers.get("Content-Type") or "").split(";")[0].strip().lower()
)
ext = MIME_EXT.get(content_type) or "png"
return data, ext


def main() -> None:
parser = argparse.ArgumentParser(
description="Archive a Figma file (document.json + images/) via REST API."
)
parser.add_argument(
"--x-figma-token",
metavar="TOKEN",
default=None,
help=f"Figma personal access token (or set {ENV_TOKEN})",
)
parser.add_argument("--filekey", required=True, help="Figma file key")
parser.add_argument(
"--archive-dir",
required=True,
type=Path,
help="Output directory (document.json and images/ will be written here)",
)
args = parser.parse_args()

token = get_token(args.x_figma_token)
archive_dir = args.archive_dir.resolve()
file_key = args.filekey.strip()

archive_dir.mkdir(parents=True, exist_ok=True)
images_dir = archive_dir / "images"
images_dir.mkdir(exist_ok=True)

print("Fetching document (geometry=paths)...", flush=True)
doc = fetch_document(file_key, token)
document_path = archive_dir / "document.json"
with open(document_path, "w", encoding="utf-8") as f:
json.dump(doc, f, indent=2, ensure_ascii=False)
doc_size = document_path.stat().st_size
print(f"Wrote document.json ({doc_size:,} bytes)", flush=True)

print("Fetching image fills list...", flush=True)
ref_to_url = fetch_image_fills(file_key, token)
n_images = len(ref_to_url)
if not ref_to_url:
print("No image fills.", flush=True)
return

print(f"Downloading {n_images} image(s)...", flush=True)
total_bytes = 0
ok = 0
for i, (ref, url) in enumerate(ref_to_url.items(), 1):
# Ref can contain characters unsafe for filenames; sanitize to alphanumeric + underscore
safe_ref = "".join(c if c.isalnum() or c in "._-" else "_" for c in ref)
if not safe_ref:
safe_ref = f"ref_{i}"
try:
data, ext = download_image(url)
out_path = images_dir / f"{safe_ref}.{ext}"
out_path.write_bytes(data)
size_k = len(data) / 1024
total_bytes += len(data)
ok += 1
print(f" [{i}/{n_images}] {safe_ref}.{ext} ({size_k:.1f} KB)", flush=True)
except Exception as e:
print(f" [{i}/{n_images}] {safe_ref} — failed: {e}", file=sys.stderr)

total_mb = total_bytes / (1024 * 1024)
print(
f"Done. document.json ({doc_size:,} B) + {ok}/{n_images} images ({total_mb:.2f} MB) in {archive_dir}.",
flush=True,
)


if __name__ == "__main__":
main()
82 changes: 82 additions & 0 deletions apps/blog/blog/refig/post.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
---
title: Introducing refig — a headless Figma renderer for real-world pipelines
description: Export Figma designs to PNG/SVG/PDF without a browser. Deterministic, CI-friendly rendering from .fig or REST JSON—built for tooling, previews, and automation.
slug: refig
authors: universe
date: 2026-02-17
tags: [figma, rendering, ci, wasm, skia, tooling]
hide_table_of_contents: false
---

Figma exports are easy—until exporting becomes **infrastructure**.

Teams start with “click Export” and end up needing **thumbnails**, **asset pipelines**, **visual diffs**, and **repeatable builds**. That’s where the usual options start to hurt:

- A headless browser is slow and flaky in CI.
- The Figma Images API is great, but it’s still **a network dependency** (tokens, rate limits, availability).
- Signed image URLs expire, which makes “render later” workflows fragile.
- Offline or air‑gapped environments simply can’t rely on API calls.

**refig** is built for that gap.

<!-- truncate -->

## What is refig?

**`@grida/refig`** (“render figma”) is a **headless Figma renderer**. It turns a Figma document + node id into **PNG, JPEG, WebP, PDF, or SVG**—without opening Figma and without driving a browser UI.

It works with:

- **`.fig` files** (offline, reproducible)
- **Figma REST API file JSON** (`GET /v1/files/:key`) when you already have your own ingestion layer

You can use it as a **CLI** (`refig`) or as a **library** in Node.js and the browser.

- Technical reference and usage: [`@grida/refig` on npm](https://www.npmjs.com/package/@grida/refig)

## The idea: deterministic rendering you can build on

When rendering is deterministic and programmable, a bunch of workflows become straightforward:

- **Preview services**: generate thumbnails for files, pages, frames, components.
- **Asset pipelines**: treat “Export presets” as build artifacts (commit, cache, publish).
- **Visual regression tests**: render the same node on every PR and diff outputs.
- **Offline archives**: store `.fig` snapshots and reproduce outputs later—no tokens, no network.
- **In-app previews**: render inside your product (browser entrypoint) to show design snapshots.

In other words: refig is less about “export an image” and more about making Figma rendering a reliable building block.

## What refig intentionally doesn’t do

refig is opinionated about scope so it stays composable:

- **No auth / fetching**: you bring your own token storage, HTTP client, and caching.
- **No design-to-code**: this is rendering (pixels / SVG / PDF), not HTML/CSS/Flutter generation.
- **No editor**: read + render only.

## A tiny taste (CLI)

If you just want to feel what it’s like:

```sh
# Render a node from a .fig export
npx @grida/refig ./design.fig --node "1:23" --out ./out.png

# Or render from REST API JSON you fetched elsewhere
npx @grida/refig ./document.json --node "1:23" --out ./out.svg
```

From there, most teams graduate quickly to: “render N nodes”, “render on every commit”, or “render on demand”.

## Where this is headed

We’re treating refig as a foundation for “design → build” automation:

- render outputs that are **repeatable**
- workflows that can run **in CI**
- tooling that can work **offline**

If that sounds like your problem, start with the docs (high-level) and the npm page (full API/CLI reference), then wire it into the pipeline you already have:

- [`@grida/refig` docs](https://grida.co/docs/packages/@grida/refig)
- [`@grida/refig` on npm](https://www.npmjs.com/package/@grida/refig)
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ const EXPECTED_FUNCTIONS = [

// images
{ name: "_add_image", paramCount: 3 },
{ name: "_add_image_with_rid", paramCount: 5 },
{ name: "_get_image_bytes", paramCount: 3 },
{ name: "_get_image_size", paramCount: 3 },

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,4 +70,94 @@ describe("raster export (node)", () => {
expectPng(data);
writePng("gradient-rect", data);
}, 30_000);

it("registers fixture image with addImageWithId and renders with custom RID", async () => {
const imagePath = resolve(process.cwd(), "../../fixtures/images/stripes.png");
const imageBytes = new Uint8Array(readFileSync(imagePath));

const doc = {
version: "0.90.0-beta+20260108",
document: {
nodes: {
"image-rect": {
id: "image-rect",
name: "image-rect",
locked: false,
active: true,
layout_positioning: "absolute",
layout_inset_top: 24,
layout_inset_left: 24,
opacity: 1,
z_index: 0,
rotation: 0,
layout_target_width: 208,
layout_target_height: 208,
type: "rectangle",
corner_radius: 16,
effects: [],
stroke_width: 0,
stroke_cap: "butt",
fill_paints: [
{
type: "image",
active: true,
src: "res://images/test-fixture-stripes",
fit: "cover",
opacity: 1,
blend_mode: "normal",
filters: {
exposure: 0,
contrast: 0,
saturation: 0,
temperature: 0,
tint: 0,
highlights: 0,
shadows: 0,
},
},
],
},
main: {
type: "scene",
id: "main",
name: "main",
active: true,
locked: false,
constraints: { children: "multiple" },
guides: [],
edges: [],
background_color: { r: 0.96, g: 0.96, b: 0.96, a: 1 },
},
},
links: { main: ["image-rect"] },
scenes_ref: ["main"],
bitmaps: {},
images: {},
properties: {},
},
};

const canvas = await createCanvas({
backend: "raster",
width: 256,
height: 256,
useEmbeddedFonts: true,
});

const result = canvas.addImageWithId(imageBytes, "res://images/test-fixture-stripes");
expect(result).not.toBe(false);
expect((result as { width: number; height: number }).width).toBeGreaterThan(0);
expect((result as { width: number; height: number }).height).toBeGreaterThan(0);

canvas.loadScene(JSON.stringify(doc));

const { data } = canvas.exportNodeAs("image-rect", {
format: "PNG",
constraints: { type: "none", value: 1 },
});

expectPng(data);
writePng("add-image-with-id-stripes", data);
canvas.dispose();
}, 30_000);
});
2 changes: 1 addition & 1 deletion crates/grida-canvas-wasm/lib/bin/grida-canvas-wasm.js

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions crates/grida-canvas-wasm/lib/bin/grida_canvas_wasm.wasm
Git LFS file not shown
Loading
Loading