Figma-to-HTML 에이전트 초기 커밋
- 10단계 변환 프로세스 (PROCESS.md) - 수학 공식 레퍼런스 (MATH.md, gradient_math.py) - CSS 보정 규칙 R1~R16 (RULES.md) - 작업 규율 7개 규칙 (PROCESS-CONTROL.md) - 8개 Figma 프레임 1:1 HTML 변환물 (block-tests/) - 8개 Jinja2 템플릿 staging (templates_staging/) - 변환 완료 도서관 + 디자인 인사이트 (blocks_index.md) - 사용법 가이드 (README.md) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
239
scripts/gradient_math.py
Normal file
239
scripts/gradient_math.py
Normal file
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
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()
|
||||
Reference in New Issue
Block a user