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>
This commit is contained in:
261
scripts/figma_to_html.py
Normal file
261
scripts/figma_to_html.py
Normal file
@@ -0,0 +1,261 @@
|
||||
"""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()
|
||||
Reference in New Issue
Block a user