Files
C.E.L_Slide_test2/scripts/figma_to_html.py
kyeongmin 05703c8e72 WIP: hero-icon-cards_1 블록 + 오답노트 + figma 관련 파일
- hero-icon-cards_1.html: hero-icon-cards 변형 (icon → 소제목+불릿 계층)
- compare-detail-gradient.html: 하단 2열 비교 블록 (Figma Frame 4 기반)
- 오답노트.md: 절대 하지 말아야 하는 실수 목록
- figma_to_html.py: Figma→HTML 변환 스크립트
- static/figma-assets/: Figma export 이미지 (배지, 화살표)
- 주의: compare-detail-gradient CSS 폰트 크기가 임의 수정됨 — 원본 복원 필요

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:14:09 +09:00

262 lines
8.7 KiB
Python

"""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", "<br>")
return (
f'<div style="position:absolute;left:{x:.1f}px;top:{y:.1f}px;'
f"width:{w:.1f}px;height:{h:.1f}px;"
f"font-size:{fs:.1f}px;font-weight:{fw};"
f"{text_style}{lh_css}{ls_css}{align_css}{valign_css}"
f'overflow:hidden;">{text_html}</div>'
)
elif ntype in ("RECTANGLE", "VECTOR"):
bg, _ = get_fill_css(visible_fills)
if not bg:
return ""
if bg == "__IMAGE__":
# 이미지 placeholder
return (
f'<div style="position:absolute;left:{x:.1f}px;top:{y:.1f}px;'
f"width:{w:.1f}px;height:{h:.1f}px;"
f'background:#ddd;border:1px solid #ccc;"></div>'
)
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'<div style="position:absolute;left:{x:.1f}px;top:{y:.1f}px;'
f"width:{w:.1f}px;height:{h:.1f}px;"
f"background:{bg};{cr_css}{stroke_css}"
f'"></div>'
)
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"""<!DOCTYPE html><html><head><meta charset="UTF-8">
<style>
*{{margin:0;padding:0;box-sizing:border-box;}}
body{{background:#e5e5e5;padding:10px;font-family:'Pretendard Variable','Noto Sans KR',sans-serif;word-break:keep-all;}}
</style></head><body>
<div style="width:{target_width}px;height:{out_h}px;position:relative;{frame_bg_css}overflow:hidden;">"""
]
# 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("</div></body></html>")
return "\n".join(parts)
def main():
if len(sys.argv) < 3:
print(
"Usage: python scripts/figma_to_html.py <figma_json> <output_dir>"
)
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()