2. SVG 안에 글자 넣는 로직 짜는데 2주가 걸렸다..
2025년 7월 1일
SVG 경로 내부에 텍스트 배치하는 로직을 만들다가 생겼던 고민 과정들과 해결 과정을 정리하였습니다.
프로젝트 초기: 단순한 시작
음성 인식 기능을 구현하기 전, 먼저 SVG 내부에 텍스트가 제대로 들어가는지 확인하는 단순한 폼을 만들었습니다.
function SimpleTextForm() { const [inputText, setInputText] = useState(""); const canvasRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); // 텍스트를 캔버스에 그리기 drawTextOnCanvas(inputText); }; return ( <form onSubmit={handleSubmit}> <input value={inputText} onChange={(e) => setInputText(e.target.value)} /> <button type="submit">텍스트 추가</button> <canvas ref={canvasRef} /> </form> ); }
이 단계에서는 텍스트를 캔버스에 그리기만 했고, SVG 경로 내부인지는 확인하지 않았습니다.
첫 번째 문제: 텍스트가 경계를 벗어남
SVG 위에 텍스트를 그리니 경계를 벗어나는 문제가 발생했습니다. SVG 내부인지 외부인지 판단하는 로직이 필요했습니다.
두 번째 시도: 내부/외부 판단 로직 구현
Canvas API의 isPointInPath()를 사용해 각 픽셀 위치가 경로 내부인지 확인하는 방식으로 접근했습니다.
// 초기 시도: 단순한 내부/외부 체크 function drawTextInPath(ctx, path, text) { const characters = text.split(""); let x = 0; let y = 0; for (const char of characters) { const width = ctx.measureText(char).width; // 현재 위치가 경로 내부인지 확인 if (ctx.isPointInPath(path, x, y)) { ctx.fillText(char, x, y); x += width; } else { // 경로 밖이면 다음 줄로 y += LINE_HEIGHT; x = 0; } } }
하지만 이 방식도 제대로 작동하지 않았습니다.
근본 원인: 복잡한 SVG 구조
문제의 원인은 SVG 파일 구조였습니다. 원본 SVG는 다음과 같은 구조였습니다:
<!-- 복잡한 원본 SVG 구조 --> <svg> <path d="M10,10 L20,20 ..."/> <!-- 꽃잎 1 --> <path d="M30,30 L40,40 ..."/> <!-- 꽃잎 2 --> <path d="M50,50 L60,60 ..."/> <!-- 꽃잎 3 --> <!-- ... 수십 개의 개별 경로들 --> <path d="M100,100 L110,110 ..."/> <!-- 줄기 --> <!-- 닫히지 않은 경로들, 겹치는 경로들 --> </svg>
문제점:
- 여러 개의 개별
<path>요소 - 일부 경로가 닫히지 않음
- 경로들이 겹치거나 분리되어 있음
- 단일 닫힌 경로가 아님
isPointInPath()는 단일 닫힌 경로(closed path)에서만 정확히 작동합니다. 여러 개의 분리된 경로나 닫히지 않은 경로에서는 내부/외부 판단이 어렵습니다.
해결책: 디자이너와의 협업
디자이너와 회의 후, SVG를 단일 닫힌 경로(실루엣)로 단순화했습니다. 전체 실루엣을 하나의 닫힌 경로로 만들어 isPointInPath()가 정확히 작동하도록 했습니다.
<!-- 단순화된 SVG 구조 --> <svg viewBox="0 0 100 100"> <path d="M50,10 C60,10 70,20 70,30 ... Z"/> <!-- 단일 닫힌 경로 --> </svg>
변경 사항:
- 여러 경로 → 단일 경로
- 열린 경로 → 닫힌 경로 (Z로 끝남)
- 전체 실루엣만 유지
최종 구현: 작동하는 로직
단순화된 SVG로 다음 로직이 정확히 작동했습니다.
1. SVG 경로 로드 및 Path2D 변환
export async function loadFlowerResources() { if (!flowerResourcesPromise) { flowerResourcesPromise = (async () => { const response = await fetch(FLOWER_SVG_URL); const svgText = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(svgText, "image/svg+xml"); const svgElement = doc.querySelector("svg"); const pathData = doc.querySelector("path")?.getAttribute("d"); if (!pathData) { throw new Error("No path found in SVG"); } let baseWidth = 0; let baseHeight = 0; const viewBox = svgElement?.getAttribute("viewBox"); if (viewBox) { const [, , width, height] = viewBox.trim().split(/\s+/).map(parseFloat); baseWidth = width; baseHeight = height; } const image = new Image(); image.src = FLOWER_SVG_URL; await new Promise((resolve, reject) => { image.onload = resolve; image.onerror = () => reject(new Error("Failed to load flower image")); }); if (!baseWidth || !baseHeight) { baseWidth = image.naturalWidth || image.width; baseHeight = image.naturalHeight || image.height; } const flowerWidth = FLOWER_TARGET_WIDTH; const flowerHeight = (baseHeight / baseWidth) * flowerWidth; const basePath = new Path2D(pathData); const scaleX = flowerWidth / baseWidth; const scaleY = flowerHeight / baseHeight; const expandedMatrix = new DOMMatrix() .scale(scaleX * FLOWER_EXPANSION_SCALE, scaleY * FLOWER_EXPANSION_SCALE) .translate(-FLOWER_OFFSET_X, 0); const expandedPath = new Path2D(); expandedPath.addPath(basePath, expandedMatrix); const originalMatrix = new DOMMatrix().scale(scaleX, scaleY); const originalPath = new Path2D(); originalPath.addPath(basePath, originalMatrix); return { image, flowerWidth, flowerHeight, expandedPath, originalPath, }; })(); } return flowerResourcesPromise; }
핵심 포인트:
doc.querySelector("path")로 단일 경로만 추출Path2D로 변환해 Canvas API에서 사용expandedPath: 텍스트 배치 영역 계산용 (약간 확대하여, 디자인 시안처럼 글자와 경계가 약간 겹치는 느낌 나게)originalPath: 실제 렌더링용
2. 한 줄씩 스캔하며 경로 내부 범위 찾기
for (let y = flowerHeight - LINE_HEIGHT_PX; y >= 0 && charIndex < characters.length; y -= LINE_HEIGHT_PX) { let x = 0; let inPath = false; let startX = 0; const ranges = []; while (x < flowerWidth) { const inside = ctx.isPointInPath(expandedPath, x, y); if (inside && !inPath) { startX = x; inPath = true; } else if (!inside && inPath) { ranges.push([startX, x]); inPath = false; } x += 1; } if (inPath) { ranges.push([startX, flowerWidth]); }
동작 방식:
- 각 줄(y 좌표)을 왼쪽에서 오른쪽으로 1픽셀씩 스캔
isPointInPath()로 내부/외부 판단- 내부 구간의 시작/끝 좌표를
ranges배열에 저장
예시:
y = 100일 때:
x: 0 50 150 250 350
[밖] [안] [밖] [안] [밖]
ranges = [[50, 150], [250, 350]]
3. 찾은 범위에 텍스트 배치
for (const [xStart, xEnd] of ranges) { let currX = xStart; while (charIndex < characters.length && currX < xEnd) { const width = ctx.measureText(characters[charIndex]).width; if (currX + width > xEnd) { break; } currX += width; charIndex += 1; } if (charIndex >= characters.length) { break; } } }
동작 방식:
- 각 내부 구간에 글자를 배치
- 글자 너비를 계산해 범위를 벗어나지 않도록 처리
- 범위를 벗어나면 다음 줄로 이동
4. 실제 렌더링에서의 적용
for (let y = canvas.height - LINE_HEIGHT_PX; y >= 0 && charIndex < characters.length; y -= LINE_HEIGHT_PX) { let x = 0; let inPath = false; let startX = 0; const ranges = []; while (x < canvas.width) { const inside = ctx.isPointInPath(expandedPath, x, y); if (inside && !inPath) { startX = x; inPath = true; } else if (!inside && inPath) { ranges.push([startX, x]); inPath = false; } x += 1; } if (inPath) { ranges.push([startX, canvas.width]); } for (const [xStart, xEnd] of ranges) { let currX = xStart; while (charIndex < characters.length && currX < xEnd) { const ch = characters[charIndex]; const width = ctx.measureText(ch).width; if (currX + width > xEnd) { break; } const isNewChar = charIndex >= previousCount; ctx.globalAlpha = isNewChar && animationState.active ? alphaForNewChars : 1; ctx.fillText(ch, currX, y); currX += width; charIndex += 1; } if (charIndex >= characters.length) { break; } } }
추가 기능:
- 하단부터 위로 쌓이는 레이아웃 (
y -= LINE_HEIGHT_PX) - 새 글자 페이드인 애니메이션
ctx.clip(originalPath)로 경로 밖 텍스트 클리핑
1. 문제의 근본 원인 파악
처음에는 코드 문제로 보였지만, 실제로는 SVG 구조 문제였습니다. 기술적 해결보다 데이터 구조 단순화가 해결책이었습니다.
2. 디자이너와의 협업
기술적 제약을 디자이너와 공유하고, 단일 닫힌 경로로 단순화하는 합의를 이뤘습니다. 이로써 기술적 요구와 디자인 의도를 모두 만족시킬 수 있었습니다.
3. 단순화의 힘
복잡한 로직보다 단순한 데이터 구조가 더 효과적일 수 있습니다. 단일 닫힌 경로로 isPointInPath()가 정확히 작동하게 되었습니다.
4. 점진적 개발
음성 인식 기능을 바로 구현하지 않고, 먼저 텍스트 배치 로직을 검증한 접근이 문제를 빠르게 발견하고 해결하는 데 도움이 되었습니다.
이와같은 시행착오들로, 결국 꽃 내부에 텍스트를 쌓는 로직 구현을 마칠 수 있었습니다.
다음은 이제 Web Speech API를 연결하여, 폼으로 입력하는 대신 사람의 음성을 연결하고자 합니다.