figma_to_html_agent/: - Figma MCP 기반 블록 추출 에이전트 (CLAUDE.md, PLAN.md, PROCESS.md 등) - block-tests/: Figma→HTML 변환 결과물 (bim-3roles-cards 등) - templates_staging/: Jinja2 템플릿 + meta.yaml + example.yaml - figma-analysis/, figma-assets/: Figma 분석 데이터 + 에셋 - scripts/: gradient_math.py 등 유틸리티 설정: - .mcp.json: Figma MCP 서버 연결 설정 - .claude/settings.json: Claude Code 프로젝트 설정 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
240 lines
6.9 KiB
Python
240 lines
6.9 KiB
Python
"""
|
||
SVG `<linearGradient>` → CSS `linear-gradient(...)` 수학 변환.
|
||
|
||
수학 원리: ../MATH.md §2
|
||
|
||
사용법 (모듈):
|
||
from scripts.gradient_math import svg_to_css
|
||
|
||
css = svg_to_css(
|
||
W=350, H=350,
|
||
x1=110.833, y1=18.2292, x2=219.479, y2=175,
|
||
stops=[(0, '#FDC69E'), (1, '#E0782C')]
|
||
)
|
||
# → "linear-gradient(145.27deg, #FDC69E 16.04%, #E0782C 55.20%)"
|
||
|
||
사용법 (CLI):
|
||
python scripts/gradient_math.py \\
|
||
--w 350 --h 350 \\
|
||
--x1 110.833 --y1 18.2292 --x2 219.479 --y2 175 \\
|
||
--stops "0:#FDC69E,1:#E0782C"
|
||
"""
|
||
|
||
import math
|
||
from typing import List, Tuple
|
||
|
||
|
||
def svg_to_css(
|
||
W: float,
|
||
H: float,
|
||
x1: float,
|
||
y1: float,
|
||
x2: float,
|
||
y2: float,
|
||
stops: List[Tuple[float, str]],
|
||
) -> str:
|
||
"""
|
||
SVG linearGradient (userSpaceOnUse) → CSS linear-gradient 문자열.
|
||
|
||
Args:
|
||
W, H: CSS 박스 크기 (background-origin이 적용되는 영역)
|
||
x1, y1: SVG 그라데이션 벡터 시작점 (이미 viewBox padding 보정된 값)
|
||
x2, y2: SVG 그라데이션 벡터 끝점
|
||
stops: [(offset, color), ...]
|
||
offset: 0.0 ~ 1.0 (SVG stop offset)
|
||
color: CSS 색상 문자열 (#RGB, #RRGGBB, rgba(), 등)
|
||
|
||
Returns:
|
||
"linear-gradient(Ndeg, color P%, color Q%, ...)"
|
||
|
||
수식:
|
||
dx, dy = x2-x1, y2-y1
|
||
L_svg = √(dx² + dy²)
|
||
css_angle= degrees(atan2(dy, dx)) + 90 (SVG y-down → CSS 12-o'clock CW)
|
||
L_css = |W·sin α| + |H·cos α| (α = radians(css_angle))
|
||
t_center = ((W/2 - x1)·dx + (H/2 - y1)·dy) / L_svg²
|
||
half = (L_css / 2) / L_svg
|
||
t0, t1 = t_center ∓ half (CSS 0%, 100% in SVG t-space)
|
||
pct = (offset - t0) / (t1 - t0) × 100
|
||
|
||
참조: ../MATH.md §2
|
||
"""
|
||
if not stops:
|
||
raise ValueError("stops 비어있음")
|
||
|
||
dx = x2 - x1
|
||
dy = y2 - y1
|
||
L_svg_sq = dx * dx + dy * dy
|
||
if L_svg_sq == 0:
|
||
raise ValueError(f"그라데이션 벡터 길이 0: ({x1},{y1})→({x2},{y2})")
|
||
L_svg = math.sqrt(L_svg_sq)
|
||
|
||
# SVG 벡터 각도 → CSS 각도 (12시 방향 0°, +CW)
|
||
svg_angle_deg = math.degrees(math.atan2(dy, dx))
|
||
css_angle = (svg_angle_deg + 90) % 360
|
||
|
||
# CSS 그라데이션 선 길이
|
||
a = math.radians(css_angle)
|
||
L_css = abs(W * math.sin(a)) + abs(H * math.cos(a))
|
||
|
||
# 박스 중심의 t 파라미터 (SVG 벡터 위에서 0=시작, 1=끝)
|
||
t_center = ((W / 2 - x1) * dx + (H / 2 - y1) * dy) / L_svg_sq
|
||
|
||
# CSS 0% / 100% ↔ SVG t-space
|
||
half = (L_css / 2) / L_svg
|
||
t0 = t_center - half
|
||
t1 = t_center + half
|
||
span = t1 - t0
|
||
if span == 0:
|
||
raise ValueError("L_css = 0 (퇴화 케이스)")
|
||
|
||
# 각 stop을 CSS percent로
|
||
parts = [f"{css_angle:.2f}deg"]
|
||
for offset, color in stops:
|
||
pct = (offset - t0) / span * 100
|
||
parts.append(f"{color} {pct:.2f}%")
|
||
|
||
return "linear-gradient(" + ", ".join(parts) + ")"
|
||
|
||
|
||
def svg_to_css_remap(
|
||
css_W: float,
|
||
css_H: float,
|
||
viewbox_padding: float,
|
||
x1: float,
|
||
y1: float,
|
||
x2: float,
|
||
y2: float,
|
||
stops: List[Tuple[float, str]],
|
||
) -> str:
|
||
"""
|
||
viewBox padding이 있는 SVG (drop-shadow 여백 등)에서 CSS box로 좌표 remap 후 변환.
|
||
|
||
예: SVG viewBox 310×310, 실제 fill 영역 280×280 (각 변 15 padding)
|
||
→ svg_to_css_remap(css_W=280, css_H=280, viewbox_padding=15, ...)
|
||
|
||
참조: ../MATH.md §5
|
||
"""
|
||
return svg_to_css(
|
||
W=css_W,
|
||
H=css_H,
|
||
x1=x1 - viewbox_padding,
|
||
y1=y1 - viewbox_padding,
|
||
x2=x2 - viewbox_padding,
|
||
y2=y2 - viewbox_padding,
|
||
stops=stops,
|
||
)
|
||
|
||
|
||
def parse_stops(stops_str: str) -> List[Tuple[float, str]]:
|
||
"""
|
||
"0:#FDC69E,1:#E0782C" → [(0.0, '#FDC69E'), (1.0, '#E0782C')]
|
||
"""
|
||
out = []
|
||
for piece in stops_str.split(","):
|
||
piece = piece.strip()
|
||
if not piece:
|
||
continue
|
||
offset_s, color = piece.split(":", 1)
|
||
out.append((float(offset_s), color.strip()))
|
||
return out
|
||
|
||
|
||
def _cli():
|
||
import argparse
|
||
|
||
p = argparse.ArgumentParser(
|
||
description="SVG linearGradient → CSS linear-gradient (수학적 변환)"
|
||
)
|
||
p.add_argument("--w", type=float, required=True, help="CSS 박스 너비")
|
||
p.add_argument("--h", type=float, required=True, help="CSS 박스 높이")
|
||
p.add_argument("--x1", type=float, required=True)
|
||
p.add_argument("--y1", type=float, required=True)
|
||
p.add_argument("--x2", type=float, required=True)
|
||
p.add_argument("--y2", type=float, required=True)
|
||
p.add_argument(
|
||
"--stops",
|
||
required=True,
|
||
help='"offset:color,offset:color" (예: "0:#FDC69E,1:#E0782C")',
|
||
)
|
||
p.add_argument(
|
||
"--viewbox-padding",
|
||
type=float,
|
||
default=0,
|
||
help="viewBox padding (drop-shadow 여백 등)",
|
||
)
|
||
|
||
args = p.parse_args()
|
||
stops = parse_stops(args.stops)
|
||
|
||
if args.viewbox_padding:
|
||
css = svg_to_css_remap(
|
||
css_W=args.w,
|
||
css_H=args.h,
|
||
viewbox_padding=args.viewbox_padding,
|
||
x1=args.x1,
|
||
y1=args.y1,
|
||
x2=args.x2,
|
||
y2=args.y2,
|
||
stops=stops,
|
||
)
|
||
else:
|
||
css = svg_to_css(
|
||
W=args.w,
|
||
H=args.h,
|
||
x1=args.x1,
|
||
y1=args.y1,
|
||
x2=args.x2,
|
||
y2=args.y2,
|
||
stops=stops,
|
||
)
|
||
|
||
print(css)
|
||
|
||
|
||
# ─── Self-test ─────────────────────────────────────────────
|
||
def _test():
|
||
"""1171281211 frame의 12개 그라데이션이 예상 값과 일치하는지 회귀 테스트"""
|
||
cases = [
|
||
# (label, args, expected_substring)
|
||
(
|
||
"big-safety outer",
|
||
dict(W=350, H=350, x1=110.833, y1=18.2292, x2=219.479, y2=175,
|
||
stops=[(0, "#FDC69E"), (1, "#E0782C")]),
|
||
"145.28deg",
|
||
),
|
||
(
|
||
"big-productivity outer",
|
||
dict(W=350, H=350, x1=300.084, y1=48.7416, x2=57.5503, y2=350,
|
||
stops=[(0, "#D5AA89"), (1, "#737373")]),
|
||
"218.84deg",
|
||
),
|
||
(
|
||
"big-trust outer",
|
||
dict(W=350, H=350, x1=56.9631, y1=11.1577, x2=272.483, y2=329.446,
|
||
stops=[(0, "#FFFFFF"), (1, "#253E1F")]),
|
||
"145.90deg",
|
||
),
|
||
]
|
||
failed = 0
|
||
for label, args, expected in cases:
|
||
css = svg_to_css(**args)
|
||
ok = expected in css
|
||
mark = "✓" if ok else "✗"
|
||
print(f" {mark} {label}: {css}")
|
||
if not ok:
|
||
failed += 1
|
||
print(f" expected substring: {expected}")
|
||
print(f"\n{len(cases) - failed}/{len(cases)} passed")
|
||
return failed == 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
import sys
|
||
|
||
if len(sys.argv) > 1 and sys.argv[1] == "--test":
|
||
ok = _test()
|
||
sys.exit(0 if ok else 1)
|
||
else:
|
||
_cli()
|