Files
Figma-to-HTML/scripts/gradient_math.py
kyeongmin beb5fd0c61 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>
2026-04-13 11:16:33 +09:00

240 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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()