""" SVG `` → 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()