> [Site index](https://perfectvector.com/llms.txt) — available pages and descriptions.

# SVG 텍스트 줄바꿈: tspan으로 측정한 줄 만들기

> 실제 글꼴로 단어 폭을 측정한 뒤 tspan을 만들어 SVG 라벨을 줄바꿈합니다. 좁은 라벨을 시험하고 넘치는 단어나 줄을 거부하며 결과를 저장합니다.

Source: https://perfectvector.com/ko/blog/svg-text-wrap-tspan

Language: ko
Author: Irene Kim
Published: 2026-10-06T08:00:00+09:00
Updated: 2026-10-06T08:00:00+09:00

내용이 바뀌는 SVG 라벨을 명시적인 줄로 나누려면 `getComputedTextLength()`로 후보 단어 묶음을 측정한 뒤 통과한 줄마다 `<tspan>` 하나를 만듭니다. 먼저 문서의 글꼴을 기다리고, 단어 하나가 라벨보다 넓거나 결과가 줄 수 제한을 넘을 때 어떻게 처리할지 정합니다.

아래 시작 코드는 공백으로 구분된 단어를 사용하며, 두 종류의 넘침 모두 부분 라벨을 만들지 않고 거부합니다. 원래의 속 빈 마름모 일러스트를 문구와 분리하므로 라벨 너비가 바뀌어도 그림을 다시 그리지 않습니다.

## 줄을 배치하기 전에 줄바꿈 정책을 정합니다

[`tspan` 요소](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/tspan)는 SVG 텍스트의 일부를 배치할 수 있게 합니다. 이 루틴은 각 줄에 같은 `x`를 설정하고 후속 줄에 `dy` 오프셋을 줍니다. 줄바꿈 결정은 루틴이 하고, 요소는 그 결과를 담습니다.

이는 명시적 배치 방법 하나입니다. [SVG 2 텍스트 명세](https://www.w3.org/TR/SVG2/text.html)는 콘텐츠 영역과 CSS 텍스트 레이아웃을 통한 자동 줄바꿈도 정의합니다. 이 글은 해당 기능을 테스트하거나 모든 브라우저에 없다고 주장하지 않습니다.

일러스트 밖의 HTML 문단에는 [CSS로 떠 있는 SVG 실루엣 주위에 텍스트를 배치합니다](https://perfectvector.com/ko/blog/css-text-around-svg-shape). 이 배치는 이미지 윤곽을 따르면서 문구를 위치 지정된 SVG 라벨 줄 대신 일반 HTML로 유지합니다.

이 작은 영어 라벨 예제의 정책은 다음과 같습니다.

* 공백을 정리하고 원래 순서의 단어를 단일 공백으로 연결해 시험합니다.
* 측정 길이가 사용 가능한 너비에 들어올 때만 후보 줄을 유지합니다.
* 단독으로도 들어가지 않는 단어는 거부하며 임의의 하이픈 분리점을 만들지 않습니다.
* `maxLines`보다 많은 줄이 필요하면 전체 결과를 거부하며 마지막 단어를 조용히 버리지 않습니다.

줄 수 제한은 배치 결정입니다. 시작 기준선, 줄 간격, 사용 가능한 높이와 함께 선택합니다. 래퍼가 세로 콘텐츠 상자를 대신 계산하지는 않습니다.

**라벨 너비를 바꾸고 그림은 유지합니다**

![속 빈 마름모 일러스트 옆의 넓은 라벨에 긴 줄이 있고 좁은 라벨에 짧은 줄이 있는 상태를 비교하는 개념도](https://perfectvector.com/blog/svg-text-wrap-tspan/measured-lines.webp)

*일러스트: 라벨이 좁으면 더 많은 줄이 필요할 수 있습니다. 추상 막대와 점선 프레임은 개념적 표시이며, 실제 문구, 측정, 거부 상태는 아래의 독립적인 브라우저 시작 코드에서 나옵니다.*

## 라벨을 그릴 글꼴로 측정합니다

[`getComputedTextLength()`](https://developer.mozilla.org/en-US/docs/Web/API/SVGTextContentElement/getComputedTextLength)는 SVG 텍스트의 계산된 길이를 측정합니다. 이 시작 코드는 연결된 같은 SVG 안에 라벨의 텍스트 요소를 숨겨진 측정용 복제본으로 만든 뒤 줄 계획을 마치면 제거합니다. 둘 다 22단위의 명시적인 `Arial, sans-serif`를 사용합니다.

코드는 측정 전에 [`document.fonts.ready`](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready)를 기다립니다. 이는 문서의 글꼴 로딩과 관련 배치를 기다릴 뿐, 내보낸 SVG에 글꼴을 포함하지 않습니다. 사용 가능한 글꼴이 다르면 줄 길이도 달라질 수 있습니다. 문구, 글꼴 설정, 사용 가능한 너비가 바뀌면 배치를 다시 실행합니다.

## 외부 의존성이 없는 완전한 시작 코드를 실행합니다

이 코드를 `starter.html`로 저장하고 브라우저에서 엽니다. 여섯 사례를 만들고 측정값을 출력하며, 각 결과 SVG를 직렬화합니다. 처음 두 사례는 라벨 너비만 바꾸고 세 번째는 문구를 바꿉니다. 나머지는 거부와 리터럴 텍스트를 시험합니다.

```html
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Measured SVG label lines</title>
<style>
  body { margin: 24px; font: 16px system-ui; background: #f6f2e8; }
  #cases { display: grid; grid-template-columns: repeat(2, 360px); gap: 20px; }
  figure { margin: 0; }
  svg { display: block; background: white; }
  figcaption { margin: 6px 0; }
  pre { white-space: pre-wrap; font-size: 12px; }
</style>
<h1>Keep artwork and label layout separate</h1>
<div id="cases"></div>
<pre id="report"></pre>
<script>
  const ns = 'http://www.w3.org/2000/svg';
  const emblem = 'M60 32 L100 72 L60 112 L20 72 Z M60 54 L78 72 L60 90 L42 72 Z';
  function element(name, attributes) {
    const node = document.createElementNS(ns, name);
    for (const [key, value] of Object.entries(attributes)) node.setAttribute(key, value);
    return node;
  }
  function wrapWords(text, source, { width, maxLines, lineHeight }) {
    text.replaceChildren();
    if (!(width > 0 && lineHeight > 0 && Number.isInteger(maxLines) && maxLines > 0)) {
      return { status: 'invalid-options', lines: [] };
    }
    const words = source.trim().split(/\s+/u).filter(Boolean);
    const probe = text.cloneNode(false);
    probe.setAttribute('visibility', 'hidden');
    text.parentNode.append(probe);
    const measure = value => { probe.textContent = value; return probe.getComputedTextLength(); };
    const lines = [];
    let line = '';
    try {
      for (const word of words) {
        const wordWidth = measure(word);
        if (wordWidth > width) {
          return { status: 'overlong-word', word, measuredWidth: wordWidth, lines: [] };
        }
        const candidate = line ? line + ' ' + word : word;
        if (line && measure(candidate) > width) {
          lines.push(line);
          line = word;
        } else line = candidate;
      }
      if (line) lines.push(line);
      if (lines.length > maxLines) {
        return { status: 'too-many-lines', requiredLines: lines.length, lines: [] };
      }
      const x = text.getAttribute('x');
      lines.forEach((value, index) => {
        const span = element('tspan', { x, dy: index ? lineHeight : 0 });
        span.textContent = value;
        text.append(span);
      });
      return { status: 'ok', lines,
        lengths: [...text.children].map(span => span.getComputedTextLength()) };
    } finally { probe.remove(); }
  }
  const cases = [
    { id: 'normal', source: 'Keep artwork and labels separate', width: 200, maxLines: 4 },
    { id: 'narrow', source: 'Keep artwork and labels separate', width: 110, maxLines: 5 },
    { id: 'changed', source: 'Keep the original artwork beside every exported label', width: 200, maxLines: 4 },
    { id: 'long-word', source: 'Artwork supercalifragilisticexpialidocious', width: 110, maxLines: 5 },
    { id: 'line-limit', source: 'Keep artwork and labels separate', width: 110, maxLines: 2 },
    { id: 'literal', source: 'Cove <g> & field', width: 200, maxLines: 4 },
  ];
  const entries = cases.map(test => {
    const figure = document.createElement('figure');
    const svg = element('svg', { width: 360, height: 240, viewBox: '0 0 360 240' });
    svg.append(element('path', { d: emblem, fill: '#176b5b', 'fill-rule': 'evenodd' }));
    svg.append(element('rect', { x: 140, y: 34, width: test.width, height: 170,
      fill: 'none', stroke: '#ce7b55', 'stroke-dasharray': '4 4' }));
    const text = element('text', { x: 140, y: 60, 'font-family': 'Arial, sans-serif',
      'font-size': 22, fill: '#172b34' });
    svg.append(text);
    const caption = document.createElement('figcaption');
    figure.append(svg, caption);
    document.getElementById('cases').append(figure);
    return { test, svg, text, caption };
  });
  async function run() {
    await document.fonts.ready;
    const results = entries.map(({ test, svg, text, caption }) => {
      const result = wrapWords(text, test.source, { ...test, lineHeight: 30 });
      caption.textContent = test.id + ': ' + result.status;
      return { ...test, ...result, fontFamily: getComputedStyle(text).fontFamily,
        fontSize: getComputedStyle(text).fontSize, tspanCount: text.children.length,
        pathD: svg.querySelector('path').getAttribute('d'),
        pathCount: svg.querySelectorAll('path').length,
        imageCount: svg.querySelectorAll('image').length,
        foreignObjectCount: svg.querySelectorAll('foreignObject').length,
        serialized: new XMLSerializer().serializeToString(svg) };
    });
    document.getElementById('report').textContent = JSON.stringify({
      fontStatus: document.fonts.status, policy: 'Whitespace words; reject a long word or too many lines without partial output', results,
    }, null, 2);
  }
  run();
</script>
</html>
```

루틴은 문구를 마크업으로 삽입하는 대신 [`textContent`](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent)로 각 줄을 만듭니다. 리터럴 텍스트 사례에서 `Cove <g> & field`는 일반 텍스트로 남습니다. 직렬화한 SVG에는 추가 그룹 요소가 아니라 이스케이프된 꺾쇠와 앰퍼샌드가 들어 있습니다.

측정용 복제본은 연결되어 있지만 숨겨져 있고, 보이는 라벨과 같은 글꼴 속성을 갖습니다. 거부될 때도 `finally`에서 제거됩니다. 단어 너비와 최대 줄 수 검사를 통과한 뒤에만 줄을 추가합니다. 루틴은 이전 라벨을 먼저 지우므로 거부된 갱신에는 이전 텍스트나 부분 텍스트가 남지 않습니다. 애플리케이션 인터페이스에서 반환 상태를 처리합니다.

## 여섯 원본 사례가 만든 결과

문서 글꼴 상태가 `loaded`인 Chrome에서 이 시작 코드를 그대로 실행했습니다. 아래 길이는 해당 환경의 SVG 텍스트 길이이며, 칠해진 글리프 경계나 글꼴 이식성 보장이 아닙니다.

| 사례            | 너비 / 최대 줄 수 | 결과                                       | 관찰한 가장 긴 줄    |
| ------------- | ----------- | ---------------------------------------- | ------------- |
| 원래 문구         | 200 / 4     | 두 줄: Keep artwork and; labels separate   | 172.44        |
| 같은 문구, 좁은 상자  | 110 / 5     | 네 줄: Keep; artwork; and labels; separate | 100.30        |
| 더 긴 문구        | 200 / 4     | 세 줄                                      | 198.13        |
| 너무 긴 단어       | 110 / 5     | overlong-word; tspan 0개                  | 단어 측정값 311.82 |
| 좁은 문구, 두 줄 제한 | 110 / 2     | too-many-lines; 네 줄 필요; tspan 0개         | 라벨을 만들지 않음    |
| 리터럴 꺾쇠와 앰퍼샌드  | 200 / 4     | 리터럴 한 줄                                  | 162.66        |

원래 문구는 `Keep artwork and labels separate`입니다. 더 긴 버전은 `Keep the original artwork beside every exported label`입니다. 통과한 모든 줄은 선택한 너비 안에 들어왔습니다. 거부 사례는 라벨만 빼고 일러스트는 보이게 유지하여 실패 상태를 쉽게 확인할 수 있었습니다.

직렬화한 여섯 파일 모두 동일한 원래 마름모 경로, 네이티브 텍스트 요소 하나를 유지했고 이미지나 `foreignObject`는 없었습니다. 통과 사례에는 각각 2개, 4개, 3개, 1개의 tspan이 있었으며 두 거부 사례는 0개였습니다. 직렬화한 여섯 SVG를 같은 브라우저에서 이미지로 다시 열었고, 모두 360×240으로 로딩됐습니다. 이 파일과 브라우저에 대한 확인이며 모든 편집기나 렌더러의 검증은 아닙니다.

## 배치한 SVG를 저장한 뒤 전달 환경을 시험합니다

[`XMLSerializer.serializeToString()`](https://developer.mozilla.org/en-US/docs/Web/API/XMLSerializer/serializeToString)은 보고서의 직렬화된 마크업을 만듭니다. 성공한 사례의 `serialized` 문자열을 `.svg` 파일로 저장합니다. 파일에는 이미 명시적인 줄이 들어 있으므로 이를 재생성하기 위해 JavaScript 루틴이 필요하지 않습니다.

문구는 여전히 편집 가능한 텍스트입니다. 저장 파일은 font-family 요청을 유지할 뿐, 글꼴 파일이나 윤곽선 글리프를 포함하지 않습니다. 다른 글꼴을 사용하는 대상에서는 줄 문자열과 위치가 같아도 글자 모양이 달라질 수 있습니다. 원래 문구와 배치 입력을 내보낸 사본 옆에 보관하고, 전달 전에 실제 대상을 확인합니다. 고정된 글자 모양이 필요하다면 원본의 편집 가능한 텍스트를 보존하면서 적절한 편집기에서 윤곽선 전달 사본을 만듭니다.

[foreignObject 텍스트 안내](https://perfectvector.com/ko/blog/svg-foreignobject-text)는 다른 렌더러에서 사라지는 HTML 라벨을 다룹니다. 이 시작 코드는 처음부터 네이티브 텍스트를 만듭니다. [textLength 안내](https://perfectvector.com/ko/blog/svg-textlength-fit)는 길이를 조절해 한 줄을 맞추며, 측정한 줄을 추가하는 것과 다른 결정을 다룹니다. 라벨 전체를 주변 그림에 맞춰 배치하려면 [세로 정렬 안내](https://perfectvector.com/ko/blog/svg-text-vertical-alignment)를 사용합니다.

## 래퍼의 한계를 명확히 합니다

공백 분리는 의도적으로 작은 정책입니다. 반복 공백, 탭, 명시적 줄바꿈을 합치며, 언어별 줄바꿈 규칙, 하이픈 처리, 양방향 문단, 여러 스타일이 섞인 서식 구간을 처리하지 않습니다. 텍스트에 이런 기능이 필요하면 다른 배치 시스템을 선택하거나 정책을 확장하고 테스트합니다.

너비 검사는 장식 글리프의 돌출 부분이 그려진 프레임 안에 머무는지, 기준선 다섯 개가 선택한 높이에 들어가는지도 보장하지 않습니다. 전달 크기에서 실제 글자를 확인합니다. 테스트 예제는 가로 라벨, 고정 글꼴 속성, 30단위 줄 간격을 사용합니다. 다시 측정하지 않고 다른 글꼴에 측정값을 적용하지 않습니다.

일러스트가 PNG나 JPG로만 있고 편집 가능한 그림이 필요하다면 [PerfectVector로 클립아트 SVG를 준비하고](https://perfectvector.com/ko/clipart-to-svg), 가장자리와 내부 구멍을 확인한 뒤 라벨과 그림을 분리해 보관합니다. 문구를 네이티브 텍스트로 다시 입력하고 줄바꿈 정책을 실행한 뒤 조합된 출력물을 확인합니다. 벡터화는 일러스트 경로를 준비하며, 줄바꿈을 선택하거나 추적한 글자에서 편집 가능한 단어를 복원하지 않습니다. 이미 좋은 경로는 그대로 둡니다. [벡터화 안내](https://perfectvector.com/ko/blog/what-is-image-vectorization)가 이 원본 복원 단계를 설명합니다.

## 자주 묻는 질문

**tspan이 각 줄의 줄바꿈 위치를 자동으로 결정하나요?**
이 시작 코드는 단어 묶음을 측정해 결정한 다음 통과한 줄마다 위치를 지정한 tspan 하나를 만듭니다. SVG 2의 CSS 기반 자동 줄바꿈은 테스트하지 않습니다.

**라벨보다 넓은 단어는 어떻게 되나요?**
루틴은 overlong-word를 반환하고 라벨을 만들지 않습니다. 조용히 잘라내는 대신 영역을 넓히거나 문구를 줄이거나 별도로 테스트한 분리 정책을 제공합니다.

**maxLines는 남은 단어를 지우나요?**
아닙니다. 이 정책은 줄이 너무 많이 필요하면 결과를 거부하고 필요한 수를 보고하며 tspan 0개를 남깁니다. 애플리케이션이 이 상태를 처리해야 합니다.

**다른 글꼴에서도 내보낸 줄이 똑같이 보이나요?**
명시적인 단어와 위치는 남지만, 파일에 글꼴이 포함되거나 글리프가 윤곽선이 되지는 않습니다. 실제 대상에서 사용 가능한 글꼴과 글자 모양을 확인합니다.

## 출처

1. [MDN tspan 요소](https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Element/tspan) — 위치 지정 하위 텍스트와 줄 오프셋 속성입니다.
2. [W3C SVG 2 텍스트](https://www.w3.org/TR/SVG2/text.html) — 명시적 위치 지정과 별도의 자동 줄바꿈 모델입니다.
3. [MDN getComputedTextLength](https://developer.mozilla.org/en-US/docs/Web/API/SVGTextContentElement/getComputedTextLength) — 실제 SVG 텍스트 측정입니다.
4. [MDN FontFaceSet.ready](https://developer.mozilla.org/en-US/docs/Web/API/FontFaceSet/ready) — 글꼴 로딩과 배치 대기입니다.
5. [MDN textContent](https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent) — 마크업 대신 일반 텍스트를 넣는 방법입니다.
6. [MDN XMLSerializer](https://developer.mozilla.org/en-US/docs/Web/API/XMLSerializer/serializeToString) — 배치된 SVG 마크업 저장입니다.

***

자신의 래스터 일러스트에는 [편집 가능한 클립아트를 준비하고](https://perfectvector.com/ko/clipart-to-svg), 도형을 확인한 뒤 원본을 보관합니다. 문구는 별도로 추가하고 전달 전에 저장 SVG에서 통과한 줄과 실제 글자를 검증합니다.

## Explore

- [Read the HTML article](https://perfectvector.com/ko/blog/svg-text-wrap-tspan)
- [SVG.js: 그림 가져오기, 경로 편집, SVG 내보내기](https://perfectvector.com/ko/blog/svgjs-import-export-artwork.md)
- [Leaflet SVG 마커: iconAnchor로 끝점 맞추기](https://perfectvector.com/ko/blog/leaflet-svg-marker-anchor.md)
- [Product facts](https://perfectvector.com/product-facts.md)
- [FAQ](https://perfectvector.com/faq.md)
- [Pricing and credits](https://perfectvector.com/pricing.md)
