- 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>
262 lines
8.7 KiB
Python
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()
|