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,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()