"""Figma 프레임을 HTML/CSS로 변환. 기존 블록 템플릿(templates/blocks/)과 동일한 방식으로 Figma API 데이터에서 정확한 HTML을 생성한다. Usage: python scripts/figma_to_html.py data/runs/figma_beps_full.json templates/blocks/BEPs/ """ from __future__ import annotations import json import math import sys from pathlib import Path def get_fill_css(fills: list[dict]) -> tuple[str, str]: """fills 배열에서 CSS background와 color를 추출. Returns: (background_css, color_css) """ bg = "" color = "" for f in fills: if not f.get("visible", True): continue ftype = f.get("type", "") if ftype == "SOLID": c = f["color"] r, g, b = int(c["r"] * 255), int(c["g"] * 255), int(c["b"] * 255) a = f.get("opacity", c.get("a", 1)) if a < 0.99: val = f"rgba({r},{g},{b},{a:.2f})" else: val = f"#{r:02x}{g:02x}{b:02x}" bg = val color = val elif "GRADIENT" in ftype: stops = f.get("gradientStops", []) handles = f.get("gradientHandlePositions", []) if len(stops) < 2: continue # 각도 계산 if len(handles) >= 2: dx = handles[1]["x"] - handles[0]["x"] dy = handles[1]["y"] - handles[0]["y"] angle = math.degrees(math.atan2(dy, dx)) + 90 else: angle = 180 stop_str = ",".join( f"#{int(s['color']['r']*255):02x}{int(s['color']['g']*255):02x}{int(s['color']['b']*255):02x} {s['position']*100:.0f}%" for s in stops ) bg = f"linear-gradient({angle:.0f}deg,{stop_str})" color = bg elif ftype == "IMAGE": bg = "__IMAGE__" return bg, color def node_to_html(node: dict, ox: float, oy: float, scale: float) -> str: """단일 노드를 HTML div로 변환.""" ntype = node.get("type", "") name = node.get("name", "") bb = node.get("absoluteBoundingBox") if not bb or not bb.get("width"): return "" x = (bb["x"] - ox) * scale y = (bb["y"] - oy) * scale w = bb["width"] * scale h = bb["height"] * scale fills = node.get("fills", []) visible_fills = [f for f in fills if f.get("visible", True)] if ntype == "TEXT": chars = node.get("characters", "") if not chars: return "" style = node.get("style", {}) fs = style.get("fontSize", 12) * scale fw = style.get("fontWeight", 400) align_h = style.get("textAlignHorizontal", "LEFT").lower() align_v = style.get("textAlignVertical", "TOP") lh_px = style.get("lineHeightPx", 0) lh = lh_px * scale if lh_px else fs * 1.5 ls = style.get("letterSpacing", 0) * scale # 텍스트 fill 처리 has_gradient = any( "GRADIENT" in f.get("type", "") for f in visible_fills ) if has_gradient: bg, _ = get_fill_css( [f for f in visible_fills if "GRADIENT" in f.get("type", "")] ) text_style = ( f"background:{bg};" f"-webkit-background-clip:text;" f"-webkit-text-fill-color:transparent;" ) else: _, c = get_fill_css(visible_fills) text_style = f"color:{c or '#000'};" lh_css = f"line-height:{lh:.1f}px;" ls_css = f"letter-spacing:{ls:.1f}px;" if ls > 0.1 else "" align_css = f"text-align:{align_h};" if align_h != "left" else "" valign_css = ( "display:flex;align-items:center;" if align_v == "CENTER" else "" ) text_html = chars.replace("\n", "
") return ( f'
{text_html}
' ) elif ntype in ("RECTANGLE", "VECTOR"): bg, _ = get_fill_css(visible_fills) if not bg: return "" if bg == "__IMAGE__": # 이미지 placeholder return ( f'
' ) cr = node.get("cornerRadius", 0) cr_css = f"border-radius:{cr * scale:.1f}px;" if cr > 0 else "" strokes = node.get("strokes", []) stroke_css = "" if strokes: for s in strokes: if not s.get("visible", True): continue sc = s.get("color", {}) sr = int(sc.get("r", 0) * 255) sg = int(sc.get("g", 0) * 255) sb = int(sc.get("b", 0) * 255) sw = node.get("strokeWeight", 1) * scale stroke_css = ( f"border:{sw:.1f}px solid #{sr:02x}{sg:02x}{sb:02x};" ) break return ( f'
' ) return "" def frame_to_html(frame: dict, target_width: int = 1280) -> str: """프레임 전체를 HTML 문서로 변환.""" bb = frame.get("absoluteBoundingBox", {}) ox, oy = bb["x"], bb["y"] fw, fh = bb["width"], bb["height"] scale = target_width / fw out_h = int(fh * scale) # 모든 리프 노드 수집 (재귀) elements: list[tuple[int, str]] = [] # (z-order, html) def collect(node: dict, z: int = 0): ntype = node.get("type", "") children = node.get("children", []) if ntype in ("GROUP", "FRAME", "CANVAS", "COMPONENT", "INSTANCE"): # 컨테이너는 자식만 순회 for i, child in enumerate(children): collect(child, z + i) else: html = node_to_html(node, ox, oy, scale) if html: elements.append((z, html)) for i, child in enumerate(children): collect(child, z + i) collect(frame, 0) # 프레임 배경 frame_fills = frame.get("fills", []) frame_bg, _ = get_fill_css( [f for f in frame_fills if f.get("visible", True)] ) frame_bg_css = f"background:{frame_bg};" if frame_bg else "background:#fff;" # z-order 순으로 정렬 (rect 먼저, text 나중) rects = [(z, h) for z, h in elements if "font-size" not in h] texts = [(z, h) for z, h in elements if "font-size" in h] parts = [ f"""
""" ] # rect를 먼저 (배경), text를 나중 (전경) for _, h in sorted(rects, key=lambda x: x[0]): parts.append(h) for _, h in sorted(texts, key=lambda x: x[0]): parts.append(h) parts.append("
") return "\n".join(parts) def main(): if len(sys.argv) < 3: print( "Usage: python scripts/figma_to_html.py " ) sys.exit(1) figma_json = Path(sys.argv[1]) output_dir = Path(sys.argv[2]) output_dir.mkdir(parents=True, exist_ok=True) data = json.loads(figma_json.read_text(encoding="utf-8")) doc = data.get("document", {}) pages = doc.get("children", []) for page in pages: for i, frame in enumerate(page.get("children", [])): if frame.get("type") not in ("FRAME", "COMPONENT"): continue name = frame.get("name", f"frame_{i}") # 파일명 정리 safe_name = ( name.replace(" ", "_") .replace("/", "_") .replace("\\", "_") ) html = frame_to_html(frame) out_path = output_dir / f"{safe_name}.html" out_path.write_text(html, encoding="utf-8") bb = frame.get("absoluteBoundingBox", {}) print( f" {safe_name}.html" f" ({bb.get('width', 0):.0f}x{bb.get('height', 0):.0f}" f" -> 1280px)" ) print(f"\n완료: {output_dir}") if __name__ == "__main__": main()