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.
- package/LICENSE +21 -0
- package/README.md +217 -0
- package/SKILL.md +1855 -0
- package/bin/install.js +42 -0
- package/package.json +23 -0
- package/references/accessibility-checklist.md +68 -0
- package/references/animation-guidelines.md +113 -0
- package/references/visual-review-checklist.md +121 -0
- package/scripts/figma_discover.py +218 -0
- package/scripts/figma_fonts.py +90 -0
- package/scripts/figma_icons.py +391 -0
- package/scripts/figma_lint.py +246 -0
- package/scripts/figma_pull.py +153 -0
- package/scripts/figma_report.py +1320 -0
- package/scripts/figma_spec.py +93 -0
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Extract every icon the page actually draws, straight out of the page SVG export.
|
|
3
|
+
|
|
4
|
+
No API call, no quota. The SVG export of the page frame contains every vector on that
|
|
5
|
+
page in page coordinates; the node JSON tells you where each icon sits. Intersect the two.
|
|
6
|
+
|
|
7
|
+
This exists so that "the icon library is behind a rate limit" is never a reason to draw an
|
|
8
|
+
icon by hand (§0.5, §6.5.5.1).
|
|
9
|
+
|
|
10
|
+
A path's bounding box is computed by flattening its curves — never by taking min/max of the
|
|
11
|
+
numbers in `d`, which is wrong for curves and relative commands and fails silently.
|
|
12
|
+
|
|
13
|
+
`visible:true, opacity:1` does not mean "renders". A component's placeholder artwork is often
|
|
14
|
+
left in the tree and painted over by a photo added later in the same frame. Those vectors are
|
|
15
|
+
skipped here: a later sibling with an opaque fill that covers the rect wins (painter's order).
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 figma_icons.py --svg design/exports/page.svg --nodes figma/nodes \
|
|
19
|
+
--out design/exports/icons [--min 10 --max 220]
|
|
20
|
+
|
|
21
|
+
Tip: strip the base64 image payloads from the SVG first; it makes it ~100x smaller.
|
|
22
|
+
"""
|
|
23
|
+
import argparse, glob, json, math, os, pathlib, re, xml.etree.ElementTree as ET
|
|
24
|
+
|
|
25
|
+
NS = "http://www.w3.org/2000/svg"
|
|
26
|
+
ET.register_namespace("", NS)
|
|
27
|
+
# What a Figma node may be made of.
|
|
28
|
+
VECTORISH = {"VECTOR", "BOOLEAN_OPERATION", "ELLIPSE", "RECTANGLE", "LINE",
|
|
29
|
+
"REGULAR_POLYGON", "STAR"}
|
|
30
|
+
# ...but only these carry the drawn outline of an icon. A subtree of nothing but plain
|
|
31
|
+
# ELLIPSE/RECTANGLE is a shape the browser draws in CSS (carousel dots, a ring, a divider),
|
|
32
|
+
# not an icon you can export.
|
|
33
|
+
GLYPHISH = {"VECTOR", "BOOLEAN_OPERATION", "REGULAR_POLYGON", "STAR"}
|
|
34
|
+
# Figma's SVG export does not emit everything as <path>. An icon whose circle is a <circle>
|
|
35
|
+
# comes out empty if you only look at paths.
|
|
36
|
+
SHAPES = {"path", "rect", "circle", "ellipse", "line", "polygon", "polyline"}
|
|
37
|
+
TOKEN = re.compile(r"([MmLlHhVvCcSsQqTtAaZz])|(-?\d*\.?\d+(?:[eE][-+]?\d+)?)")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def path_bbox(d):
|
|
41
|
+
"""Flatten the path and return (x0,y0,x1,y1). Curves are sampled, not guessed."""
|
|
42
|
+
pts = path_points(d)
|
|
43
|
+
if not pts:
|
|
44
|
+
return None
|
|
45
|
+
xs = [p[0] for p in pts]
|
|
46
|
+
ys = [p[1] for p in pts]
|
|
47
|
+
return min(xs), min(ys), max(xs), max(ys)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def path_points(d):
|
|
51
|
+
"""Flatten the path to a point list. Curves are sampled, not guessed."""
|
|
52
|
+
xs, ys = [], []
|
|
53
|
+
cmd, nums, cur, start = None, [], (0.0, 0.0), (0.0, 0.0)
|
|
54
|
+
|
|
55
|
+
def add(p):
|
|
56
|
+
xs.append(p[0]); ys.append(p[1])
|
|
57
|
+
|
|
58
|
+
def bez(p0, p1, p2, p3, n=16):
|
|
59
|
+
for i in range(n + 1):
|
|
60
|
+
t = i / n
|
|
61
|
+
u = 1 - t
|
|
62
|
+
add((u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0],
|
|
63
|
+
u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1]))
|
|
64
|
+
|
|
65
|
+
def flush():
|
|
66
|
+
nonlocal cur, start, nums
|
|
67
|
+
if cmd is None:
|
|
68
|
+
return
|
|
69
|
+
c, rel = cmd.upper(), cmd.islower()
|
|
70
|
+
i = 0
|
|
71
|
+
while True:
|
|
72
|
+
if c == "M":
|
|
73
|
+
if i + 2 > len(nums): break
|
|
74
|
+
p = (nums[i], nums[i+1]); i += 2
|
|
75
|
+
cur = (cur[0]+p[0], cur[1]+p[1]) if rel else p
|
|
76
|
+
start = cur; add(cur)
|
|
77
|
+
c = "L" # subsequent pairs are implicit lineto
|
|
78
|
+
elif c == "L":
|
|
79
|
+
if i + 2 > len(nums): break
|
|
80
|
+
p = (nums[i], nums[i+1]); i += 2
|
|
81
|
+
cur = (cur[0]+p[0], cur[1]+p[1]) if rel else p
|
|
82
|
+
add(cur)
|
|
83
|
+
elif c == "H":
|
|
84
|
+
if i + 1 > len(nums): break
|
|
85
|
+
x = nums[i]; i += 1
|
|
86
|
+
cur = (cur[0]+x, cur[1]) if rel else (x, cur[1]); add(cur)
|
|
87
|
+
elif c == "V":
|
|
88
|
+
if i + 1 > len(nums): break
|
|
89
|
+
y = nums[i]; i += 1
|
|
90
|
+
cur = (cur[0], cur[1]+y) if rel else (cur[0], y); add(cur)
|
|
91
|
+
elif c == "C":
|
|
92
|
+
if i + 6 > len(nums): break
|
|
93
|
+
pts = [(nums[i+k], nums[i+k+1]) for k in (0, 2, 4)]; i += 6
|
|
94
|
+
if rel: pts = [(cur[0]+p[0], cur[1]+p[1]) for p in pts]
|
|
95
|
+
bez(cur, pts[0], pts[1], pts[2]); cur = pts[2]
|
|
96
|
+
elif c == "Z":
|
|
97
|
+
cur = start; add(cur); break
|
|
98
|
+
else: # Q/S/T/A: endpoints only, good enough
|
|
99
|
+
if i + 2 > len(nums): break
|
|
100
|
+
p = (nums[-2], nums[-1])
|
|
101
|
+
cur = (cur[0]+p[0], cur[1]+p[1]) if rel else p
|
|
102
|
+
add(cur); break
|
|
103
|
+
nums = []
|
|
104
|
+
|
|
105
|
+
for m in TOKEN.finditer(d):
|
|
106
|
+
if m.group(1):
|
|
107
|
+
flush(); cmd = m.group(1)
|
|
108
|
+
else:
|
|
109
|
+
nums.append(float(m.group(2)))
|
|
110
|
+
flush()
|
|
111
|
+
return list(zip(xs, ys))
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _opaque(n):
|
|
115
|
+
if n.get("visible") is False or n.get("opacity", 1) < 0.99:
|
|
116
|
+
return False
|
|
117
|
+
for f in n.get("fills") or []:
|
|
118
|
+
if f.get("visible") is False or f.get("opacity", 1) < 0.99:
|
|
119
|
+
continue
|
|
120
|
+
if f.get("type") == "IMAGE":
|
|
121
|
+
return True
|
|
122
|
+
if f.get("type") == "SOLID" and f.get("color", {}).get("a", 1) >= 0.99:
|
|
123
|
+
return True
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _covers(n, bb, pad=0.5):
|
|
128
|
+
"""Does n, or any descendant, paint an opaque box over bb?"""
|
|
129
|
+
if n.get("visible") is False or n.get("opacity", 1) < 0.99:
|
|
130
|
+
return False
|
|
131
|
+
b = n.get("absoluteBoundingBox") or {}
|
|
132
|
+
if b and _opaque(n):
|
|
133
|
+
if (b["x"] - pad <= bb["x"] and b["y"] - pad <= bb["y"]
|
|
134
|
+
and b["x"] + b["width"] + pad >= bb["x"] + bb["width"]
|
|
135
|
+
and b["y"] + b["height"] + pad >= bb["y"] + bb["height"]):
|
|
136
|
+
return True
|
|
137
|
+
return any(_covers(c, bb, pad) for c in n.get("children") or [])
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def occluded(bb, stack):
|
|
141
|
+
"""stack is [(parent, index_of_child_on_path), ...] from the root down.
|
|
142
|
+
Anything later in paint order, at any level, can bury this rect."""
|
|
143
|
+
for parent, i in stack:
|
|
144
|
+
for later in (parent.get("children") or [])[i + 1:]:
|
|
145
|
+
if _covers(later, bb):
|
|
146
|
+
return True
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _nums(el, *names):
|
|
151
|
+
return [float(el.get(n, 0) or 0) for n in names]
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def elem_bbox(el):
|
|
155
|
+
"""bbox of any SVG shape element, in user units."""
|
|
156
|
+
tag = el.tag.split("}")[-1]
|
|
157
|
+
if tag == "path":
|
|
158
|
+
return path_bbox(el.get("d") or "")
|
|
159
|
+
if tag == "rect":
|
|
160
|
+
x, y, w, h = _nums(el, "x", "y", "width", "height")
|
|
161
|
+
return (x, y, x + w, y + h)
|
|
162
|
+
if tag == "circle":
|
|
163
|
+
cx, cy, r = _nums(el, "cx", "cy", "r")
|
|
164
|
+
return (cx - r, cy - r, cx + r, cy + r)
|
|
165
|
+
if tag == "ellipse":
|
|
166
|
+
cx, cy, rx, ry = _nums(el, "cx", "cy", "rx", "ry")
|
|
167
|
+
return (cx - rx, cy - ry, cx + rx, cy + ry)
|
|
168
|
+
if tag == "line":
|
|
169
|
+
x1, y1, x2, y2 = _nums(el, "x1", "y1", "x2", "y2")
|
|
170
|
+
return (min(x1, x2), min(y1, y2), max(x1, x2), max(y1, y2))
|
|
171
|
+
if tag in ("polygon", "polyline"):
|
|
172
|
+
pts = [float(v) for v in re.findall(r"-?\d*\.?\d+", el.get("points") or "")]
|
|
173
|
+
if len(pts) < 2:
|
|
174
|
+
return None
|
|
175
|
+
xs, ys = pts[0::2], pts[1::2]
|
|
176
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
177
|
+
return None
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def elem_points(el):
|
|
181
|
+
"""Every shape reduced to a point list, so two drawings can be compared."""
|
|
182
|
+
tag = el.tag.split("}")[-1]
|
|
183
|
+
if tag == "path":
|
|
184
|
+
return path_points(el.get("d") or "")
|
|
185
|
+
b = elem_bbox(el)
|
|
186
|
+
if not b:
|
|
187
|
+
return []
|
|
188
|
+
if tag in ("rect", "line"):
|
|
189
|
+
return [(b[0], b[1]), (b[2], b[3])]
|
|
190
|
+
if tag in ("circle", "ellipse"):
|
|
191
|
+
cx, cy = (b[0] + b[2]) / 2, (b[1] + b[3]) / 2
|
|
192
|
+
return [(b[0], cy), (cx, b[1]), (b[2], cy), (cx, b[3])]
|
|
193
|
+
pts = [float(v) for v in re.findall(r"-?\d*\.?\d+", el.get("points") or "")]
|
|
194
|
+
return list(zip(pts[0::2], pts[1::2]))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def icon_signature(elems):
|
|
198
|
+
"""Fingerprint what an icon DRAWS: its outline and its paint.
|
|
199
|
+
|
|
200
|
+
Two constraints make a hash the wrong tool here:
|
|
201
|
+
|
|
202
|
+
* The same icon reused on another card lands on sub-pixel-different coordinates. A hash
|
|
203
|
+
of the geometry turns a 0.0008px difference into a total mismatch.
|
|
204
|
+
* A filled star and an outlined star have *identical* geometry and differ only in
|
|
205
|
+
`fill`. A geometry-only fingerprint calls them the same icon.
|
|
206
|
+
|
|
207
|
+
So: a scale- and translation-invariant shape profile that is compared with a tolerance,
|
|
208
|
+
plus the paint, compared exactly.
|
|
209
|
+
"""
|
|
210
|
+
shapes = [(el, elem_points(el)) for el in elems]
|
|
211
|
+
pts = [p for _, ps in shapes for p in ps]
|
|
212
|
+
if not pts:
|
|
213
|
+
return "", ""
|
|
214
|
+
|
|
215
|
+
paint = "|".join(
|
|
216
|
+
";".join((el.get(k) or "-") for k in
|
|
217
|
+
("fill", "stroke", "stroke-width", "fill-rule", "opacity"))
|
|
218
|
+
for el, _ in shapes)
|
|
219
|
+
|
|
220
|
+
x0 = min(p[0] for p in pts); x1 = max(p[0] for p in pts)
|
|
221
|
+
y0 = min(p[1] for p in pts); y1 = max(p[1] for p in pts)
|
|
222
|
+
span = max(x1 - x0, y1 - y0) or 1.0
|
|
223
|
+
cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
|
|
224
|
+
|
|
225
|
+
# Quantiles, not angular bins. A bin boundary turns a 0.001px wobble into a 0.06 jump
|
|
226
|
+
# in the profile; a quantile moves by the size of the wobble and nothing else.
|
|
227
|
+
def quantiles(vals, k=16):
|
|
228
|
+
vals = sorted(vals)
|
|
229
|
+
n = len(vals) - 1
|
|
230
|
+
return [vals[round(i * n / (k - 1))] for i in range(k)]
|
|
231
|
+
|
|
232
|
+
xs = [(px - x0) / span for px, _ in pts]
|
|
233
|
+
ys = [(py - y0) / span for _, py in pts]
|
|
234
|
+
rs = [math.hypot((px - cx) / span, (py - cy) / span) for px, py in pts]
|
|
235
|
+
prof = quantiles(xs) + quantiles(ys) + quantiles(rs)
|
|
236
|
+
return ",".join(f"{v:.3f}" for v in prof), paint
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def icon_rects(nodes_dir, lo, hi, absolute=False):
|
|
240
|
+
"""Outermost pure-vector subtrees that actually render, in page coordinates.
|
|
241
|
+
|
|
242
|
+
With absolute=True the boxes are also reported in Figma's own absolute coordinates, so
|
|
243
|
+
another tool can key on them without re-deriving the origin."""
|
|
244
|
+
def only_vec(n):
|
|
245
|
+
if n.get("type") == "TEXT":
|
|
246
|
+
return False
|
|
247
|
+
k = n.get("children") or []
|
|
248
|
+
return all(only_vec(c) for c in k) if k else n.get("type") in VECTORISH
|
|
249
|
+
|
|
250
|
+
def has_glyph(n):
|
|
251
|
+
"""An icon has a drawn outline somewhere in it. Dots, rings and dividers do not."""
|
|
252
|
+
return n.get("type") in GLYPHISH or any(has_glyph(c) for c in n.get("children") or [])
|
|
253
|
+
|
|
254
|
+
def splittable(n):
|
|
255
|
+
"""A frame holding several separate icons (a pager's two arrows, a five-star row)
|
|
256
|
+
is a container, not an icon. Recurse into it. Guard: only when the children are
|
|
257
|
+
themselves containers — the letters of a logo are bare VECTORs and must stay whole."""
|
|
258
|
+
kids = [c for c in (n.get("children") or []) if c.get("visible") is not False]
|
|
259
|
+
if len(kids) < 2 or any(k.get("type") in VECTORISH for k in kids):
|
|
260
|
+
return False
|
|
261
|
+
bbs = [k.get("absoluteBoundingBox") or {} for k in kids]
|
|
262
|
+
if not all(b and lo <= b["width"] <= hi and lo <= b["height"] <= hi for b in bbs):
|
|
263
|
+
return False
|
|
264
|
+
for i, a_ in enumerate(bbs): # pairwise disjoint?
|
|
265
|
+
for b_ in bbs[i + 1:]:
|
|
266
|
+
if (a_["x"] < b_["x"] + b_["width"] and b_["x"] < a_["x"] + a_["width"]
|
|
267
|
+
and a_["y"] < b_["y"] + b_["height"] and b_["y"] < a_["y"] + a_["height"]):
|
|
268
|
+
return False
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
def has_image(n):
|
|
272
|
+
"""A RECTANGLE or ELLIPSE with an IMAGE fill is a photo, whatever its type says.
|
|
273
|
+
Treating it as an icon exports the box and sweeps up every path behind it."""
|
|
274
|
+
if any(f.get("type") == "IMAGE" and f.get("visible") is not False
|
|
275
|
+
for f in n.get("fills") or []):
|
|
276
|
+
return True
|
|
277
|
+
return any(has_image(c) for c in n.get("children") or [])
|
|
278
|
+
|
|
279
|
+
docs = {}
|
|
280
|
+
for f in glob.glob(os.path.join(nodes_dir, "*.json")):
|
|
281
|
+
d = json.load(open(f))
|
|
282
|
+
docs[pathlib.Path(f).stem] = d["document"] if "document" in d else d
|
|
283
|
+
ox = min((d.get("absoluteBoundingBox") or {}).get("x", 0) for d in docs.values())
|
|
284
|
+
oy = min((d.get("absoluteBoundingBox") or {}).get("y", 0) for d in docs.values())
|
|
285
|
+
|
|
286
|
+
out, buried = [], 0
|
|
287
|
+
for name, doc in docs.items():
|
|
288
|
+
def walk(n, op=1.0, stack=()):
|
|
289
|
+
nonlocal buried
|
|
290
|
+
if n.get("visible") is False:
|
|
291
|
+
return
|
|
292
|
+
op *= n.get("opacity", 1)
|
|
293
|
+
if op == 0:
|
|
294
|
+
return
|
|
295
|
+
bb = n.get("absoluteBoundingBox") or {}
|
|
296
|
+
w, h = bb.get("width", 0), bb.get("height", 0)
|
|
297
|
+
if bb and lo <= w <= hi and lo <= h <= hi and only_vec(n) and has_glyph(n) \
|
|
298
|
+
and not has_image(n) and not splittable(n):
|
|
299
|
+
if occluded(bb, stack):
|
|
300
|
+
buried += 1 # placeholder artwork under a photo; never renders
|
|
301
|
+
return
|
|
302
|
+
out.append((name, bb["x"] - ox, bb["y"] - oy, w, h, bb["x"], bb["y"]))
|
|
303
|
+
return
|
|
304
|
+
for i, c in enumerate(n.get("children", []) or []):
|
|
305
|
+
walk(c, op, stack + ((n, i),))
|
|
306
|
+
walk(doc)
|
|
307
|
+
if buried:
|
|
308
|
+
print(f"skipped {buried} vector(s) buried under an opaque fill (they never render)")
|
|
309
|
+
return out
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def main():
|
|
313
|
+
ap = argparse.ArgumentParser()
|
|
314
|
+
ap.add_argument("--svg", required=True)
|
|
315
|
+
ap.add_argument("--nodes", default="figma/nodes")
|
|
316
|
+
ap.add_argument("--out", default="design/exports/icons")
|
|
317
|
+
ap.add_argument("--min", type=int, default=10)
|
|
318
|
+
ap.add_argument("--max", type=int, default=220)
|
|
319
|
+
ap.add_argument("--tol", type=float, default=1.5)
|
|
320
|
+
a = ap.parse_args()
|
|
321
|
+
|
|
322
|
+
root = ET.parse(a.svg).getroot()
|
|
323
|
+
paths = [n for n in root.iter() if n.tag.split("}")[-1] in SHAPES]
|
|
324
|
+
boxes = [elem_bbox(n) for n in paths]
|
|
325
|
+
paths, boxes = zip(*[(n, b) for n, b in zip(paths, boxes) if b]) or ((), ())
|
|
326
|
+
paths, boxes = list(paths), list(boxes)
|
|
327
|
+
print(f"{len(paths)} shape elements in the SVG")
|
|
328
|
+
|
|
329
|
+
rects = icon_rects(a.nodes, a.min, a.max)
|
|
330
|
+
|
|
331
|
+
# A pure-vector subtree can still be a GROUP of icons (a tile holding a badge, a
|
|
332
|
+
# toolbar holding three glyphs). If one rect strictly contains another, the outer one
|
|
333
|
+
# is a group: exporting it yields two icons stacked on top of each other.
|
|
334
|
+
def contains(a_, b_, pad=1.0):
|
|
335
|
+
return (a_[1] - pad <= b_[1] and a_[2] - pad <= b_[2]
|
|
336
|
+
and a_[1] + a_[3] + pad >= b_[1] + b_[3]
|
|
337
|
+
and a_[2] + a_[4] + pad >= b_[2] + b_[4]
|
|
338
|
+
and (a_[3] * a_[4]) > (b_[3] * b_[4]) * 1.2)
|
|
339
|
+
|
|
340
|
+
groups = {i for i, r in enumerate(rects)
|
|
341
|
+
if any(j != i and contains(r, o) for j, o in enumerate(rects))}
|
|
342
|
+
if groups:
|
|
343
|
+
print(f"dropped {len(groups)} rects that contain other icons (they are groups)")
|
|
344
|
+
rects = [r for i, r in enumerate(rects) if i not in groups]
|
|
345
|
+
print(f"{len(rects)} icon rects in the node JSON\n")
|
|
346
|
+
|
|
347
|
+
outdir = pathlib.Path(a.out); outdir.mkdir(parents=True, exist_ok=True)
|
|
348
|
+
made, empty, manifest = 0, [], {}
|
|
349
|
+
seen = {}
|
|
350
|
+
for name, x, y, w, h, ax, ay in rects:
|
|
351
|
+
# Number by RECT, before any skip. The fidelity report re-derives these names from
|
|
352
|
+
# the same node JSON with the same rules; a name that shifts when one icon happens
|
|
353
|
+
# to be empty would silently compare the wrong file.
|
|
354
|
+
i = seen.get(name, 0) + 1
|
|
355
|
+
seen[name] = i
|
|
356
|
+
sel = [(n, b) for n, b in zip(paths, boxes)
|
|
357
|
+
if b and b[0] >= x - a.tol and b[1] >= y - a.tol
|
|
358
|
+
and b[2] <= x + w + a.tol and b[3] <= y + h + a.tol]
|
|
359
|
+
if not sel:
|
|
360
|
+
empty.append((name, round(x), round(y)))
|
|
361
|
+
continue
|
|
362
|
+
# viewBox = the icon NODE's box, not the ink box. Cropping to the ink makes every
|
|
363
|
+
# icon render larger than the design (a 12px glyph in a 40px button fills the button)
|
|
364
|
+
# and silently loses the padding the designer drew.
|
|
365
|
+
# NOTE: register_namespace already emits xmlns; passing it again breaks the file.
|
|
366
|
+
# width/height too: an <img> with no intrinsic size falls back to 300x150 and the
|
|
367
|
+
# icon renders at whatever the surrounding flexbox allows.
|
|
368
|
+
shape_sig, paint_sig = icon_signature([n for n, _ in sel])
|
|
369
|
+
svg = ET.Element(f"{{{NS}}}svg", {
|
|
370
|
+
"width": f"{w:g}", "height": f"{h:g}",
|
|
371
|
+
"viewBox": f"{x:.2f} {y:.2f} {w:.2f} {h:.2f}", "fill": "none",
|
|
372
|
+
# lets a verifier ask "is this the icon the design puts here?" after the file
|
|
373
|
+
# has been renamed, copied, resized or reused on another node
|
|
374
|
+
"data-icon-shape": shape_sig, "data-icon-paint": paint_sig})
|
|
375
|
+
for n, _ in sel:
|
|
376
|
+
svg.append(n)
|
|
377
|
+
fn = f"{name}-{i:02d}.svg"
|
|
378
|
+
ET.ElementTree(svg).write(outdir / fn, encoding="unicode")
|
|
379
|
+
# keyed on the node's absolute box: the one identifier that cannot drift
|
|
380
|
+
manifest[f"{name}|{round(ax)}|{round(ay)}"] = fn
|
|
381
|
+
made += 1
|
|
382
|
+
|
|
383
|
+
(outdir / "icons.json").write_text(json.dumps(manifest, indent=1, sort_keys=True))
|
|
384
|
+
print(f"wrote {made} icons to {outdir}/ (+ icons.json, the node->file manifest)")
|
|
385
|
+
if empty:
|
|
386
|
+
print(f"{len(empty)} icon rects had no vector inside them (shapes drawn as frames?): {empty[:6]}")
|
|
387
|
+
print("\nNow OPEN THEM AND LOOK. A bad crop is a plausible-looking blob.")
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Build-stage guard: catch values you invented before the fidelity report does.
|
|
3
|
+
|
|
4
|
+
Everything in the design is a number that exists in the node JSON. If your CSS uses a
|
|
5
|
+
gap, padding or font-size that appears nowhere in the file, you guessed it — and guessed
|
|
6
|
+
values are exactly what a per-text-node audit later reports as 100+ offsets.
|
|
7
|
+
|
|
8
|
+
Checks
|
|
9
|
+
1. every `gap:` / `padding:` px value in the CSS exists as an `itemSpacing` or padding
|
|
10
|
+
in the design
|
|
11
|
+
2. every `font-size:` px value exists as a `fontSize`
|
|
12
|
+
3. the number of `<br>` in the HTML does not exceed the number of TEXT nodes whose
|
|
13
|
+
`characters` actually contain a newline
|
|
14
|
+
4. every colour literal in the CSS exists as a solid fill in the design
|
|
15
|
+
5. no hand-drawn inline `<svg>` where the design ships a vector you can export
|
|
16
|
+
6. every visible TEXT `characters` string appears in the HTML source (page text or
|
|
17
|
+
placeholder/aria-label/value/alt) — missing or reworded copy is caught here, before
|
|
18
|
+
the browser reports it as a hundred "not found in DOM" rows
|
|
19
|
+
|
|
20
|
+
Hidden nodes and nodes with effective ancestor opacity 0 are ignored — they render
|
|
21
|
+
nothing, so their numbers are not design values.
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
python3 figma_lint.py --css css/styles.css --html index.html --nodes figma/nodes
|
|
25
|
+
"""
|
|
26
|
+
import argparse, glob, html as html_lib, json, os, re, sys
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def design_values(nodes_dir):
|
|
30
|
+
gaps, pads, sizes, colours, newline_texts = set(), set(), set(), set(), 0
|
|
31
|
+
texts = []
|
|
32
|
+
families = set()
|
|
33
|
+
|
|
34
|
+
def hexes(node):
|
|
35
|
+
out = []
|
|
36
|
+
for f in node.get("fills") or []:
|
|
37
|
+
if f.get("visible") is False or f.get("type") != "SOLID":
|
|
38
|
+
continue
|
|
39
|
+
c = f["color"]
|
|
40
|
+
out.append("#%02x%02x%02x" % tuple(round(c[k] * 255) for k in "rgb"))
|
|
41
|
+
return out
|
|
42
|
+
|
|
43
|
+
for path in glob.glob(os.path.join(nodes_dir, "*.json")):
|
|
44
|
+
d = json.load(open(path))
|
|
45
|
+
d = d["document"] if "document" in d else d
|
|
46
|
+
|
|
47
|
+
def walk(n, op=1.0):
|
|
48
|
+
nonlocal newline_texts
|
|
49
|
+
if n.get("visible") is False:
|
|
50
|
+
return
|
|
51
|
+
op *= n.get("opacity", 1)
|
|
52
|
+
if op == 0:
|
|
53
|
+
return
|
|
54
|
+
if n.get("itemSpacing"):
|
|
55
|
+
gaps.add(round(n["itemSpacing"]))
|
|
56
|
+
for k in ("paddingLeft", "paddingTop", "paddingRight", "paddingBottom"):
|
|
57
|
+
if n.get(k):
|
|
58
|
+
pads.add(round(n[k]))
|
|
59
|
+
if n.get("type") == "TEXT":
|
|
60
|
+
st = n.get("style", {})
|
|
61
|
+
if st.get("fontSize"):
|
|
62
|
+
sizes.add(round(st["fontSize"]))
|
|
63
|
+
if st.get("fontFamily"):
|
|
64
|
+
families.add(st["fontFamily"])
|
|
65
|
+
for o in (n.get("styleOverrideTable") or {}).values():
|
|
66
|
+
if o.get("fontSize"):
|
|
67
|
+
sizes.add(round(o["fontSize"]))
|
|
68
|
+
if "\n" in (n.get("characters") or ""):
|
|
69
|
+
newline_texts += 1
|
|
70
|
+
chars = n.get("characters") or ""
|
|
71
|
+
if st.get("textCase") == "UPPER":
|
|
72
|
+
chars = chars.upper()
|
|
73
|
+
texts.append(chars)
|
|
74
|
+
colours.update(hexes(n))
|
|
75
|
+
for c in n.get("children", []) or []:
|
|
76
|
+
walk(c, op)
|
|
77
|
+
|
|
78
|
+
walk(d)
|
|
79
|
+
return gaps, pads, sizes, colours, newline_texts, texts, families
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def custom_props(css):
|
|
83
|
+
"""--name: value, so `gap: var(--space-6)` can be resolved instead of skipped.
|
|
84
|
+
Hiding an invented number inside a custom property is the obvious way to defeat
|
|
85
|
+
this linter, so we follow them."""
|
|
86
|
+
return {m.group(1): m.group(2).strip()
|
|
87
|
+
for m in re.finditer(r"(--[\w-]+)\s*:\s*([^;{}]+);", css)}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def resolve(val, props, depth=0):
|
|
91
|
+
if depth > 4:
|
|
92
|
+
return val
|
|
93
|
+
def sub(m):
|
|
94
|
+
return props.get(m.group(1), "")
|
|
95
|
+
out = re.sub(r"var\(\s*(--[\w-]+)[^)]*\)", sub, val)
|
|
96
|
+
return resolve(out, props, depth + 1) if "var(" in out else out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def css_numbers(css, prop, props):
|
|
100
|
+
"""px values for a property, after resolving var(). Responsive functions are skipped:
|
|
101
|
+
clamp()/min()/max() legitimately hold non-design values for other viewports."""
|
|
102
|
+
out = []
|
|
103
|
+
for m in re.finditer(rf"(?<![\w-]){prop}\s*:\s*([^;{{}}]+);", css):
|
|
104
|
+
val = resolve(m.group(1), props)
|
|
105
|
+
if any(fn in val for fn in ("clamp(", "min(", "max(", "calc(")):
|
|
106
|
+
continue
|
|
107
|
+
out += [int(x) for x in re.findall(r"(\d+)px", val)]
|
|
108
|
+
return out
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main():
|
|
112
|
+
ap = argparse.ArgumentParser()
|
|
113
|
+
ap.add_argument("--css", required=True)
|
|
114
|
+
ap.add_argument("--html", required=True)
|
|
115
|
+
ap.add_argument("--nodes", default="figma/nodes")
|
|
116
|
+
ap.add_argument("--tolerance", type=int, default=0,
|
|
117
|
+
help="allow a CSS value within N px of a design value")
|
|
118
|
+
ap.add_argument("--allow-inline-svg", type=int, default=0,
|
|
119
|
+
help="number of inline <svg> you have justified in the difference log "
|
|
120
|
+
"(a vector the design draws as a shape and you reproduce in CSS)")
|
|
121
|
+
a = ap.parse_args()
|
|
122
|
+
|
|
123
|
+
gaps, pads, sizes, colours, newline_texts, texts, families = design_values(a.nodes)
|
|
124
|
+
css = open(a.css).read()
|
|
125
|
+
html = open(a.html).read()
|
|
126
|
+
props = custom_props(css)
|
|
127
|
+
|
|
128
|
+
def near(v, allowed):
|
|
129
|
+
return any(abs(v - x) <= a.tolerance for x in allowed)
|
|
130
|
+
|
|
131
|
+
problems = []
|
|
132
|
+
|
|
133
|
+
spacing = set(gaps) | set(pads)
|
|
134
|
+
bad_gap = sorted({v for v in css_numbers(css, "gap", props) if not near(v, spacing)})
|
|
135
|
+
if bad_gap:
|
|
136
|
+
problems.append(("gap", bad_gap, "not an itemSpacing or padding anywhere in the design"))
|
|
137
|
+
|
|
138
|
+
bad_pad = sorted({v for v in css_numbers(css, "padding", props) if not near(v, spacing)})
|
|
139
|
+
if bad_pad:
|
|
140
|
+
problems.append(("padding", bad_pad, "not an itemSpacing or padding anywhere in the design"))
|
|
141
|
+
|
|
142
|
+
bad_size = sorted({v for v in css_numbers(css, "font-size", props) if not near(v, sizes)})
|
|
143
|
+
if bad_size:
|
|
144
|
+
problems.append(("font-size", bad_size, "no TEXT node uses this size"))
|
|
145
|
+
|
|
146
|
+
css_hex = {h.lower() for h in re.findall(r"#[0-9a-fA-F]{6}", css)}
|
|
147
|
+
design_hex = {c.lower() for c in colours}
|
|
148
|
+
bad_col = sorted(css_hex - design_hex)
|
|
149
|
+
if bad_col:
|
|
150
|
+
problems.append(("colour", bad_col, "no solid fill in the design uses this hex"))
|
|
151
|
+
|
|
152
|
+
# Fonts must be ACTUALLY WIRED, not just named. Two silent-fallback traps:
|
|
153
|
+
# (a) an @font-face whose src file does not exist on disk → the browser drops it
|
|
154
|
+
# and falls back to a metrically-different face, changing every line break
|
|
155
|
+
# (FM82/85) while the position audit stays green.
|
|
156
|
+
# (b) a design font-family that appears in NO CSS font stack → nothing requests it.
|
|
157
|
+
# A licensed webfont's Figma name ("The Seasons") and its web token ("the-seasons")
|
|
158
|
+
# differ, so compare on a normalised key (lowercase, alphanumerics only).
|
|
159
|
+
def norm(s):
|
|
160
|
+
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
161
|
+
|
|
162
|
+
css_dir = os.path.dirname(os.path.abspath(a.css))
|
|
163
|
+
missing_face = []
|
|
164
|
+
for block in re.findall(r"@font-face\s*{([^}]*)}", css):
|
|
165
|
+
fam = re.search(r"font-family\s*:\s*[\"']?([^;\"']+)", block)
|
|
166
|
+
fam = fam.group(1).strip() if fam else "?"
|
|
167
|
+
for url in re.findall(r"url\(\s*[\"']?([^)\"']+)", block):
|
|
168
|
+
if url.startswith(("http://", "https://", "data:")):
|
|
169
|
+
continue
|
|
170
|
+
p = os.path.normpath(os.path.join(css_dir, url.split("?")[0].split("#")[0]))
|
|
171
|
+
if not os.path.exists(p):
|
|
172
|
+
missing_face.append(f'{fam}: {url}')
|
|
173
|
+
if missing_face:
|
|
174
|
+
problems.append(("@font-face src missing", sorted(set(missing_face)),
|
|
175
|
+
"the file does not exist on disk, so the browser silently falls "
|
|
176
|
+
"back to a different face — line breaks and metrics will not match "
|
|
177
|
+
"the design. Supply the file or use the real webfont <link>"))
|
|
178
|
+
|
|
179
|
+
# every design font-family must be referenced somewhere in the CSS (a `font-family`
|
|
180
|
+
# stack OR a `--font-*` custom property that a stack resolves through). Compare on the
|
|
181
|
+
# normalised key against the whole normalised stylesheet so 'The Seasons' matches a
|
|
182
|
+
# 'the-seasons' token and a family held only in a custom property still counts.
|
|
183
|
+
css_norm = norm(css)
|
|
184
|
+
unwired = sorted({f for f in families if norm(f) not in css_norm})
|
|
185
|
+
if unwired:
|
|
186
|
+
problems.append(("design font not in any CSS font stack", unwired,
|
|
187
|
+
"this typeface is used in the design but no `font-family` in the "
|
|
188
|
+
"CSS names it (Figma reports the display name, e.g. 'The Seasons'; "
|
|
189
|
+
"the web token may be 'the-seasons' — either is fine, but SOMETHING "
|
|
190
|
+
"must reference it or the text falls back to a system font)"))
|
|
191
|
+
|
|
192
|
+
inline_svg = len(re.findall(r"<svg\b", html))
|
|
193
|
+
if inline_svg > a.allow_inline_svg:
|
|
194
|
+
problems.append(("inline <svg>", [f"{inline_svg} hand-drawn (allowed: {a.allow_inline_svg})"],
|
|
195
|
+
"icons must be exported from the design, not redrawn from memory; "
|
|
196
|
+
"justify any genuine CSS-shape exception with --allow-inline-svg"))
|
|
197
|
+
|
|
198
|
+
brs = len(re.findall(r"<br\s*/?>", html))
|
|
199
|
+
if brs > newline_texts:
|
|
200
|
+
problems.append(("<br>", [f"{brs} in HTML vs {newline_texts} newlines in `characters`"],
|
|
201
|
+
"you invented line breaks; the copy no longer matches the design"))
|
|
202
|
+
|
|
203
|
+
# Every visible string the design shows must appear, verbatim, in the HTML. Missing or
|
|
204
|
+
# reworded copy is the single largest source of "not found in DOM" in the fidelity
|
|
205
|
+
# report; catching it here means fixing it before the browser ever runs. The check is on
|
|
206
|
+
# the HTML SOURCE, so text carried by placeholder=/aria-label=/value= counts as present.
|
|
207
|
+
def flat(s):
|
|
208
|
+
return re.sub(r"\s+", " ", (s or "")).strip().lower()
|
|
209
|
+
|
|
210
|
+
haystack = flat(html_lib.unescape(re.sub(r"<[^>]+>", " ", html)))
|
|
211
|
+
attrs = " ".join(re.findall(r'(?:placeholder|aria-label|value|alt|title)="([^"]*)"', html))
|
|
212
|
+
haystack += " " + flat(html_lib.unescape(attrs))
|
|
213
|
+
missing = []
|
|
214
|
+
for t in texts:
|
|
215
|
+
for line in t.split("\n"): # a node's own newlines may become separate tags
|
|
216
|
+
f = flat(line)
|
|
217
|
+
if len(f) >= 2 and f not in haystack:
|
|
218
|
+
missing.append(line.strip()[:48])
|
|
219
|
+
if missing:
|
|
220
|
+
uniq = sorted(set(missing))
|
|
221
|
+
problems.append(("missing copy", uniq[:20] + ([f"... {len(uniq)-20} more"] if len(uniq) > 20 else []),
|
|
222
|
+
"these strings are in the design but not in the HTML — invented, "
|
|
223
|
+
"reworded or dropped copy"))
|
|
224
|
+
|
|
225
|
+
print("Design vocabulary")
|
|
226
|
+
print(f" spacing values : {sorted(spacing)}")
|
|
227
|
+
print(f" font sizes : {sorted(sizes)}")
|
|
228
|
+
print(f" solid colours : {len(design_hex)}")
|
|
229
|
+
print(f" texts with \\n : {newline_texts}")
|
|
230
|
+
print(f" text strings : {len(texts)}\n")
|
|
231
|
+
|
|
232
|
+
if not problems:
|
|
233
|
+
print("OK — every CSS value is a value the design actually uses.")
|
|
234
|
+
return
|
|
235
|
+
|
|
236
|
+
print("INVENTED VALUES — these appear in your CSS but nowhere in the design:\n")
|
|
237
|
+
for prop, vals, why in problems:
|
|
238
|
+
print(f" {prop}: {vals}")
|
|
239
|
+
print(f" {why}\n")
|
|
240
|
+
print("Fix these before running the fidelity report. Guessed spacing is what turns into")
|
|
241
|
+
print("a hundred y-offsets in the text audit.")
|
|
242
|
+
sys.exit(1)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
main()
|