figma-to-html-pixel-perfect 1.0.0

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.
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env python3
2
+ """Fetch node JSON + one render per section from the Figma REST API.
3
+
4
+ Implements §6.5.1 (preflight) and §6.5.2 (spend the render budget first).
5
+
6
+ Deliberately does NOT bulk-download image fills: that burns the shared quota the
7
+ renders need. Fetch assets afterwards, only for the refs you actually use
8
+ (see `download_refs`).
9
+
10
+ Token: env FIGMA_TOKEN, else ~/.figma_token (scope: File content: Read)
11
+
12
+ Usage:
13
+ python3 figma_pull.py <fileKey> <nodeId>[,<nodeId>...] [outdir]
14
+ python3 figma_pull.py <fileKey> --hover <destId>[,<destId>...] [outdir]
15
+
16
+ `--hover` fetches the variant nodes that `interactions[].destinationId` points at (find them
17
+ with figma_discover.py) and renders each one. Those renders are the reference for the hover
18
+ state; nothing else in this toolkit can tell you what a hover is supposed to look like.
19
+
20
+ Writes:
21
+ <outdir>/nodes/<nodeId>.json full node tree
22
+ <outdir>/renders/<nodeId>.png one render per node <-- LOOK AT THESE
23
+ """
24
+ import json, os, sys, time, pathlib, urllib.parse, urllib.request
25
+
26
+ API = "https://api.figma.com/v1"
27
+
28
+
29
+ def token():
30
+ if os.environ.get("FIGMA_TOKEN"):
31
+ return os.environ["FIGMA_TOKEN"].strip()
32
+ p = pathlib.Path.home() / ".figma_token"
33
+ if p.exists():
34
+ return p.read_text().strip()
35
+ sys.exit("No token. figma.com > Settings > Security > Personal access tokens\n"
36
+ "then: echo 'TOKEN' > ~/.figma_token && chmod 600 ~/.figma_token")
37
+
38
+
39
+ def get(path, tok):
40
+ req = urllib.request.Request(API + path, headers={"X-Figma-Token": tok})
41
+ try:
42
+ with urllib.request.urlopen(req, timeout=90) as r:
43
+ return json.load(r)
44
+ except urllib.error.HTTPError as e:
45
+ body = e.read(300).decode(errors="replace")
46
+ if e.code == 429:
47
+ ra = e.headers.get("Retry-After")
48
+ hrs = f" ({int(ra)/3600:.1f} h)" if ra and ra.isdigit() else ""
49
+ sys.exit(f"429 rate limited. Retry-After: {ra}{hrs}\n"
50
+ f"Do not plan around a quick reset. {body}")
51
+ if e.code == 403 and "not exportable" in body:
52
+ sys.exit("403 File not exportable — the owner disabled export/copy/share.\n"
53
+ "Only the owner (or an editor) can lift this. Nothing else will work.")
54
+ sys.exit(f"HTTP {e.code}: {body}")
55
+
56
+
57
+ def main():
58
+ if len(sys.argv) < 3:
59
+ sys.exit(__doc__)
60
+ argv = sys.argv[1:]
61
+ hover = False
62
+ if "--hover" in argv:
63
+ hover = True
64
+ argv.remove("--hover")
65
+ force = "--force" in argv
66
+ if force:
67
+ argv.remove("--force")
68
+ key, ids = argv[0], [i.strip().replace("-", ":") for i in argv[1].split(",")]
69
+ out = pathlib.Path(argv[2] if len(argv) > 2 else "figma")
70
+ tok = token()
71
+ # CACHE-FIRST. Every REST endpoint (including /v1/files) sits under a plan-based
72
+ # monthly quota and 429s with Retry-After up to hours/days. A node that is already
73
+ # on disk is NEVER re-fetched — the cache is the workspace, the API is only for
74
+ # what is missing. Use --force to deliberately refresh.
75
+ if not force:
76
+ cached = [i for i in ids if (out / "nodes" / f"{i.replace(':','-')}.json").exists()]
77
+ if cached:
78
+ print(f"cache-first: {len(cached)}/{len(ids)} node(s) already in {out}/nodes — skipped "
79
+ f"(--force to re-fetch)")
80
+ ids = [i for i in ids if i not in cached]
81
+ if not ids:
82
+ return
83
+ if hover:
84
+ (out / "hover").mkdir(parents=True, exist_ok=True)
85
+ nodes = get(f"/files/{key}/nodes?ids={urllib.parse.quote(','.join(ids))}", tok)
86
+ for nid in ids:
87
+ n = nodes["nodes"].get(nid)
88
+ if n:
89
+ (out / "hover" / f"{nid.replace(':','-')}.json").write_text(
90
+ json.dumps(n, indent=2, ensure_ascii=False))
91
+ for nid in ids:
92
+ r = get(f"/images/{key}?ids={urllib.parse.quote(nid)}&format=png&scale=2", tok)
93
+ url = (r.get("images") or {}).get(nid)
94
+ if not url:
95
+ print(f" !! no render for hover variant {nid}")
96
+ continue
97
+ with urllib.request.urlopen(url, timeout=240) as im:
98
+ (out / "hover" / f"{nid.replace(':','-')}.png").write_bytes(im.read())
99
+ print(f" hover variant {nid}")
100
+ time.sleep(1)
101
+ print("\nLook at these. Then make your :hover match them (§9.0).")
102
+ return
103
+
104
+ # §6.5.1 preflight — fail fast with the real reason
105
+ get(f"/files/{key}?depth=1", tok)
106
+ print("preflight ok: file is readable and exportable")
107
+
108
+ (out / "nodes").mkdir(parents=True, exist_ok=True)
109
+ (out / "renders").mkdir(parents=True, exist_ok=True)
110
+
111
+ # 1) node trees (single call)
112
+ nodes = get(f"/files/{key}/nodes?ids={urllib.parse.quote(','.join(ids))}", tok)
113
+ for nid in ids:
114
+ n = nodes["nodes"].get(nid)
115
+ if n:
116
+ (out / "nodes" / f"{nid.replace(':','-')}.json").write_text(
117
+ json.dumps(n, indent=2, ensure_ascii=False))
118
+ print(f"saved {len(ids)} node trees -> {out}/nodes/")
119
+
120
+ # 2) ONE RENDER PER SECTION — the scarce budget. Fail loudly, never silently.
121
+ failed = []
122
+ for nid in ids:
123
+ r = get(f"/images/{key}?ids={urllib.parse.quote(nid)}&format=png&scale=1", tok)
124
+ url = (r.get("images") or {}).get(nid)
125
+ if not url:
126
+ failed.append(nid)
127
+ print(f" !! NO RENDER for {nid} (err={r.get('err')})", flush=True)
128
+ continue
129
+ with urllib.request.urlopen(url, timeout=240) as im:
130
+ (out / "renders" / f"{nid.replace(':','-')}.png").write_bytes(im.read())
131
+ print(f" rendered {nid}", flush=True)
132
+ time.sleep(1)
133
+
134
+ if failed:
135
+ sys.exit(f"\nBLOCKED: {len(failed)} section(s) have no reference render: {failed}\n"
136
+ "Per §6.5.0 these must NOT be implemented from geometry alone.\n"
137
+ "Report them to the user and ask for screenshots.")
138
+ print("\nAll sections rendered. Now OPEN EVERY RENDER before writing any code.")
139
+
140
+
141
+ def download_refs(key, refs, dest="assets"):
142
+ """Fetch only the imageRefs you actually use. Call after the renders exist."""
143
+ tok = token()
144
+ m = get(f"/files/{key}/images", tok)["meta"]["images"]
145
+ pathlib.Path(dest).mkdir(parents=True, exist_ok=True)
146
+ for name, ref in refs.items():
147
+ with urllib.request.urlopen(m[ref], timeout=240) as r:
148
+ pathlib.Path(f"{dest}/{name}.png").write_bytes(r.read())
149
+ print("saved", name)
150
+
151
+
152
+ if __name__ == "__main__":
153
+ main()