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>
10 KiB
수학 공식 레퍼런스
Figma → HTML 변환에서 사용하는 모든 수학 공식. 이 문서의 공식만 사용하고, 직관/감으로 보정하지 않는다.
§1. 스케일 팩터
정의
S = 1280 / W_원본_프레임
1280은 16:9 슬라이드 가로 폭. 모든 프레임은 가로 1280에 맞춰 축소된다.
적용 방법: CSS transform scale (권장)
<div class="block">
<div class="inner"> <!-- 원본 W × H 좌표계 그대로 -->
... 모든 요소 (Figma 원본 px) ...
</div>
</div>
.block {
width: 1280px;
height: {H × S}px;
overflow: hidden;
position: relative;
}
.inner {
position: absolute;
left: 0; top: 0;
width: {W}px; /* 원본 그대로 */
height: {H}px;
transform: scale({S});
transform-origin: top left;
}
왜 transform이 좋은가:
- 위치/크기/폰트/그림자/스트로크/blur radius 모두 한 번에 균일 축소
- 매 값 수동 곱셈하면 누적 오차 + 검증 어려움
- transform은 GPU 가속, 계산 정확
적용 대상
| 적용 | 미적용 |
|---|---|
| 위치 (x, y) | 색상 |
| 크기 (width, height) | 그라데이션 방향 (각도 그대로) |
| 폰트 크기 | 그라데이션 stop 퍼센트 (그대로) |
| 스트로크 너비 | 폰트 굵기 |
| 간격 (gap, padding) | line-height 비율 (1.5 등) |
| 그림자 (blur, offset) | border-radius 비율 (50% 등) |
| border-radius (px) |
§2. SVG <linearGradient> → CSS linear-gradient()
입력
SVG에서:
<linearGradient id="..." gradientUnits="userSpaceOnUse"
x1="..." y1="..." x2="..." y2="...">
<stop offset="0" stop-color="..."/>
<stop offset="1" stop-color="..."/>
</linearGradient>
변환 공식
1. dx = x2 - x1
dy = y2 - y1
L_svg = √(dx² + dy²)
2. SVG 벡터 각도 (y-down 좌표계, 0°=오른쪽, +CW):
svg_angle = atan2(dy, dx) (단위: 라디안)
3. CSS 각도 (12시 방향=0°, +CW):
css_angle = degrees(svg_angle) + 90
css_angle = css_angle mod 360
4. CSS 그라데이션 선 길이 (W×H 박스 안):
α = radians(css_angle)
L_css = |W × sin(α)| + |H × cos(α)|
5. 박스 중심의 t 파라미터 (SVG 벡터 위, 0=시작, 1=끝):
t_center = ((W/2 - x1)·dx + (H/2 - y1)·dy) / L_svg²
6. CSS 0% / 100%가 SVG t-space의 어디에 매핑되는지:
half = (L_css / 2) / L_svg
t0 = t_center - half ← CSS 0%
t1 = t_center + half ← CSS 100%
7. SVG 각 stop offset (0~1)을 CSS percent로:
pct = (offset - t0) / (t1 - t0) × 100
예시
SVG:
<linearGradient x1="110.833" y1="18.2292" x2="219.479" y2="175"
gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#FDC69E"/>
<stop offset="1" stop-color="#E0782C"/>
</linearGradient>
박스 W=H=350일 때:
dx = 108.65, dy = 156.77
L_svg = √(108.65² + 156.77²) = 190.74
svg_angle = atan2(156.77, 108.65) = 0.9646 rad = 55.27°
css_angle = 55.27 + 90 = 145.27°
α = 2.535 rad
L_css = 350 × |sin 145.27°| + 350 × |cos 145.27°|
= 350 × 0.5696 + 350 × 0.8220
= 487.06
t_center = ((175 - 110.833)·108.65 + (175 - 18.229)·156.77) / 190.74²
= (6971.7 + 24577.3) / 36382
= 0.8672
half = (487.06 / 2) / 190.74 = 1.2767
t0 = 0.8672 - 1.2767 = -0.4095
t1 = 0.8672 + 1.2767 = 2.1439
SVG offset 0 → pct = (0 - (-0.4095)) / 2.5534 × 100 = 16.04%
SVG offset 1 → pct = (1 - (-0.4095)) / 2.5534 × 100 = 55.20%
CSS:
background: linear-gradient(145.27deg, #FDC69E 16.04%, #E0782C 55.20%);
코드: scripts/gradient_math.py
from scripts.gradient_math import svg_to_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%)"
§3. 회전 감지 (bbox 비율 검사)
Figma MCP는 rotation 속성을 출력하지 않으므로 bbox 비율로 추론:
단일 문자 텍스트:
width > height × 1.5 → 90° 회전 (가로로 누움)
일반 텍스트:
width < fontSize × 0.8 → 좁은 박스 세로 배치 (writing-mode 아님, <br>로 줄바꿈)
CSS 적용:
.rotated {
transform: rotate(90deg); /* 또는 -90deg */
}
§4. Descender 보정 (padding-bottom)
CSS line-height: 1이거나 < font_content_area_ratio이면 글리프 하강부(g, y, p, 쉼표)가 잘림.
폰트별 메트릭
| 폰트 | UPM | typoAscender | typoDescender | content_area_ratio |
|---|---|---|---|---|
| Noto Sans KR | 1000 | 1160 | 288 | 1.448 |
| Pretendard | 1000 | 1100 | 300 | 1.400 |
공식
content_area_ratio = (typoAscender + |typoDescender|) / UPM
half_leading = (line_height_ratio - content_area_ratio) / 2
↑ 음수면 잘림 발생
clipped_px = |half_leading| × font_size
padding-bottom = ceil(clipped_px)
예시 (Noto Sans KR, font 27.1px, lh 1)
half_leading = (1.0 - 1.448) / 2 = -0.224
clipped = 0.224 × 27.1 = 6.07 px
→ padding-bottom: 7px
예시 (Noto Sans KR, font 30px, lh 35px → ratio 1.167)
half_leading = (1.167 - 1.448) / 2 = -0.1405
clipped = 0.1405 × 30 = 4.215 px
→ padding-bottom: 5px
§5. SVG viewBox padding → CSS box-sizing 매핑
SVG가 drop-shadow blur 여백을 위해 viewBox를 확장해놓은 경우 (예: 280×280 fill을 310 viewBox에 넣음):
케이스 A — Stroke가 fill 외부 (안전과 품질 ring 같은 케이스)
SVG: viewBox 310, fill r=140 (d=280), stroke r=142.5 width=5 (extends r=140 to 145)
visible: 290×290 (fill 280 + 5px stroke 외부 확장)
viewBox padding: 310 - 290 = 20 (각 변 10이 drop-shadow blur 패딩, 추가 5는 stroke)
CSS:
div W=H=290
border: 5px solid white
box-sizing: border-box
→ border-box 290, padding-box 280 ← fill 영역
position: Figma fill 위치 - (5, 5) ← stroke 외부 확장 보정
케이스 B — Stroke가 fill 내부 (생산성/소통 ring 같은 케이스)
SVG: viewBox 300, fill r=140, stroke r=137.5 width=5 (extends r=135 to 140 — fill 외곽 5px overlap)
visible: 280×280 (stroke가 fill 외곽 5px를 덮음)
viewBox padding: 300 - 280 = 20 (전부 drop-shadow blur)
CSS:
div W=H=280
border: 5px solid white
box-sizing: border-box
background-origin: border-box ← gradient를 border-box 280에 매핑
background-clip: border-box
→ border 5가 외곽 fill을 덮어 그라데이션 가시 영역은 270
position: Figma 위치 그대로 (offset 없음)
그라데이션 좌표 remap
SVG <linearGradient> 좌표는 viewBox 공간 기준. CSS box로 매핑할 때:
viewBox padding이 P (예: 15 또는 10)이라면:
CSS_x = SVG_x - P
CSS_y = SVG_y - P
이렇게 보정한 좌표를 §2의 svg_to_css 공식에 W=H=fill_size로 넣는다.
§6. Drop shadow: SVG feGaussianBlur ↔ CSS box-shadow
SVG:
<filter>
<feGaussianBlur stdDeviation="5"/>
<feColorMatrix .../>
</filter>
CSS 근사:
box-shadow: 0 0 {2 × stdDeviation}px {color};
stdDeviation=5 → CSS box-shadow: 0 0 10px black
주의: 정확한 픽셀 일치는 아님. 시각적으로 매우 유사하지만 SVG 가우시안과 CSS 블러 알고리즘이 다름. ±2px 차이는 허용.
§7. Blend mode 호환
Figma가 사용하는 blend mode → CSS 호환 매핑
| Figma | CSS 정확 | CSS 호환 (Chrome/Firefox) | 비고 |
|---|---|---|---|
| Normal | normal | normal | 기본 |
| Multiply | multiply | multiply | OK |
| Plus darker | plus-darker | multiply | plus-darker는 Safari 전용 |
| Darken | darken | darken | OK |
| Screen | screen | screen | OK |
| Overlay | overlay | overlay | OK |
Plus-darker vs Multiply 차이
plus-darker(src, dst) = max(0, src + dst - 1)
multiply(src, dst) = src × dst
- 흰 배경: 둘 다 동일 (효과 없음)
- 어두운 배경: multiply가 plus-darker보다 강하게 어두워짐
- 밝은 그라데이션 + 흰 배경 조합: 시각적 차이 거의 없음 (이 프로젝트 디자인 대부분 해당)
→ Chrome/Firefox 호환 위해 multiply로 통일. RULES.md R10 참조.
§8. CSS border-radius 비율 변환
Figma cornerRadius는 px 단위. CSS도 px 단위 그대로 사용 + scale 적용.
특수 케이스:
- 완전 원:
border-radius: 50% - 캡슐:
border-radius: {height/2}px - 한쪽만 둥근 사각:
border-radius: {tl} {tr} {br} {bl}(개별 4값)
스케일링 시: scale transform이 자동으로 px 값을 비율 유지하며 축소함. 별도 계산 불필요.
§9. 글자 수 추정 (블록 안에 들어갈 텍스트 양)
블록 너비/높이에서 들어갈 수 있는 한글 글자 수를 미리 계산:
한 줄 글자 수 = 블록 너비(px) / (font_size × 한글_글자_너비_계수)
줄 수 = 블록 높이(px) / (font_size × line_height_ratio)
총 글자 수 = 한 줄 × 줄 수 × 안전계수(0.85)
Pretendard / Noto Sans KR 한글 글자 너비 계수 = 0.97
| font-size | 한글 글자 너비 | line_height 1.6 줄 높이 |
|---|---|---|
| 12px | 11.6px | 19.2px |
| 16px | 15.5px | 25.6px |
| 20px | 19.4px | 32.0px |
| 24px | 23.3px | 38.4px |
| 30px | 29.1px | 48.0px |
이는 design_agent 텍스트 편집 단계에서 사용. 변환 단계에서는 직접 사용하지 않음.
검증 체크리스트
변환 후 매번 확인:
- §1 스케일 —
transform: scale(S)한 번만 사용했는가, 매 값 수동 곱셈은 없는가 - §2 그라데이션 — gradient_math.py로 도출한 값을 그대로 사용했는가, 눈대중 각도/stop은 없는가
- §3 회전 — bbox 비율로 회전 감지했는가
- §4 descender —
line_height < content_area_ratio인 텍스트에 padding-bottom 추가했는가 - §5 viewBox — stroke 정렬 확인 (외부/내부)에 따라 box-sizing 적용했는가
- §6 shadow —
box-shadow blur = 2 × stdDeviation인가 - §7 blend —
plus-darker를multiply로 교체했는가