PerfectVector
著者: Irene Kim

SVGのテキスト折り返し:tspanで計測済みの行を作る

実際のフォントで単語を計測し、tspanを生成してSVGラベルを折り返します。狭い幅を試し、長すぎる単語や行数超過を拒否して結果を保存します。

4 分で読める
目次

変化するSVGラベルを明示的な行に折り返すには、候補となる単語のまとまりをgetComputedTextLength()で計測し、採用した各行に一つの<tspan>を書き込みます。まずドキュメントのフォントを待ち、一つの単語がラベルより広い場合や、結果が行数制限を超える場合の動作を決めてください。

以下のひな形は空白で区切られた単語を使い、どちらの超過も部分的なラベルを出力せずに拒否します。独自の中空ひし形イラストと文言を分けているため、ラベル幅を変えてもアートワークを描き直しません。

行を配置する前に折り返し方針を選ぶ

tspan要素では、SVGテキストの一部を配置できます。この処理は各行に同じxを設定し、二行目以降にdyのオフセットを設定します。改行位置は処理が判断し、要素は結果を保持します。

これは明示的なレイアウトの一つの方法です。SVG 2のテキスト仕様は、コンテンツ領域とCSSテキストレイアウトによる自動折り返しも定義しています。この記事はその機能をテストせず、すべてのブラウザーにないと主張するものでもありません。

イラストの外のHTML段落には、CSSでフロートしたSVGのシルエットにテキストを回り込ませる方法を使ってください。そのレイアウトは画像の輪郭に従い、文言は通常のHTMLのままで、位置指定したSVGラベルの行にはなりません。

この小さな英語ラベルの例では、次の方針を使います。

  • 連続する空白をまとめ、単語を元の順序で、一つのスペースで区切って試します。
  • 計測した長さが利用可能な幅に収まる間だけ、候補の行を残します。
  • 単独でも収まらない単語は拒否し、ハイフネーション位置を勝手に作りません。
  • maxLinesを超える場合は結果全体を拒否し、最後の単語を黙って省きません。

行数制限はレイアウトの判断です。開始ベースライン、行間、利用可能な高さと一緒に選んでください。この折り返し処理は、縦方向のコンテンツボックスを計算しません。

ラベル幅を変え、アートワークを保つ
中空ひし形のイラストの横で、幅広いラベルの長い行と狭いラベルの短い行を比較する概念図
模式図:ラベルが狭いと行数が増える場合があります。抽象的な棒と点線の枠は概念的な目印です。以下の実際の文言、計測、拒否状態は、独立したブラウザーのひな形に基づきます。

ラベルを描くフォントで計測する

getComputedTextLength()は、SVGテキストの計算済みの長さを測ります。このひな形は、ラベルのtext要素を、同じ接続済みSVG内の非表示の計測用要素として複製し、行の計画後に削除します。両方とも、22単位のArial, sans-serifを明示的に使います。

コードは計測前にdocument.fonts.readyを待ちます。ドキュメントのフォント読み込みと関連するレイアウトを待つもので、書き出しSVGにフォントを埋め込むものではありません。別の利用可能なフォントでは、行の長さが変わる場合があります。文言、フォント設定、利用可能な幅が変わったら、レイアウトを再実行してください。

依存のない完全なひな形を実行する

これをstarter.htmlとして保存し、ブラウザーで開きます。六つのケースを作り、計測結果を表示して、各結果SVGをシリアライズします。最初の二つはラベル幅だけを変え、三つ目は文言を変えます。残りは拒否とリテラルのテキストを確認します。

<!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で各行を作ります。リテラルテキストのケースでは、Cove <g> & fieldは通常のテキストのままです。シリアライズしたSVGには、追加のグループ要素ではなく、エスケープされた山かっことアンパサンドが含まれます。

計測用の複製は、接続されていますが非表示で、見えるラベルと同じフォント属性を持ちます。拒否の場合も含め、finallyで削除します。単語幅と最大行数の確認が成功してから、行を追加します。最初に以前のラベルを消すので、拒否された更新は古いテキストや部分的なテキストを残しません。アプリケーションの画面側で、返された状態を処理してください。

六つの元ケースの結果

ドキュメントのフォントがloadedを報告するChromeで、このひな形をそのまま実行しました。以下の長さはその環境でのSVGテキスト長で、描画されたグリフの範囲や、フォントの可搬性の保証ではありません。

ケース幅 / 最大行数結果観察した最長行
元のフレーズ200 / 4二行:Keep artwork and; labels separate172.44
同じフレーズ、狭い枠110 / 5四行:Keep; artwork; and labels; separate100.30
長い文言200 / 4三行198.13
長すぎる単語110 / 5overlong-word、tspanはゼロ単語の計測幅311.82
狭いフレーズ、二行制限110 / 2too-many-lines、必要行数は四、tspanはゼロラベルを出力しない
リテラルの山かっことアンパサンド200 / 4リテラルの一行162.66

元のフレーズはKeep artwork and labels separate、長い版はKeep the original artwork beside every exported labelです。採用した行はすべて、選んだ幅以内と計測されました。拒否ケースはイラストを表示したままラベルを省くため、失敗状態を確認しやすくなっています。

シリアライズした六つのファイルはすべて、同一の元のひし形パス、一つのネイティブtext要素を保ち、画像やforeignObjectはありませんでした。採用ケースのtspan数は、それぞれ二、四、三、一で、拒否した二つのケースはゼロでした。また六つのシリアライズ済みSVGを同じブラウザーで画像として開き直し、すべて360×240で読み込まれました。これはこれらのファイルとこのブラウザーの確認であり、すべてのエディターやレンダラーの確認ではありません。

レイアウト済みSVGを保存してから利用先をテストする

XMLSerializer.serializeToString()は、報告内のシリアライズ済みマークアップを作ります。成功したケースのserialized文字列を.svgファイルとして保存してください。ファイルにはすでに明示的な行があり、再現のためにこのJavaScript処理は必要ありません。

単語は生きたテキストのままです。保存ファイルが保持するのはfont-familyの指定で、フォントファイルやアウトライン化したグリフではありません。利用先のフォントが異なると、行の文字列と位置が残っていても、文字の見た目が変わる場合があります。元の文言とレイアウト入力を、書き出したコピーと一緒に保管し、納品前に実際の利用先で確認してください。字形を固定するには、適切な元データ用エディターでアウトライン化した納品用コピーを作り、マスターには生きたテキストを残します。

foreignObjectのテキストガイドは、別のレンダラーで消えるHTMLラベルを扱います。このひな形は最初からネイティブテキストを作ります。textLengthガイドは、長さを調整して一行を収める方法を扱い、計測した行を追加する場合とは異なる判断です。ラベル全体を周囲のアートワークに対して配置する必要があるときは、垂直方向の位置合わせガイドを使ってください。

折り返し処理の制約を明確にする

空白での分割は、意図的に小さくした方針です。連続するスペース、タブ、明示的な改行をまとめ、言語固有の禁則や改行規則、ハイフネーション、双方向段落、複数のスタイルを混ぜたリッチなspanには対応しません。その機能が必要なら、別のレイアウトシステムを選ぶか、方針を拡張してテストしてください。

幅の確認は、装飾的なグリフのはみ出しが枠内に収まることや、五つのベースラインが選んだ高さに収まることも保証しません。納品サイズで見える文字を確認してください。このテストは水平ラベル、固定のフォント属性、30単位の行間隔を使います。再計測せずに、数値を別の書体に当てはめないでください。

イラストがPNGやJPGでしか存在せず、編集可能なアートワークが必要なら、PerfectVectorでクリップアートSVGを準備し、縁と内部の開口部を確認して、ラベルと別に保管します。文言はネイティブテキストとして打ち直し、折り返し方針を実行して、組み上げた書き出しを確認してください。ベクター化はイラストのパスを準備するもので、改行位置を選んだり、トレースした文字から編集可能な単語を復元したりしません。良好な既存パスは、そのまま残せます。ベクター化ガイドが、その元データ復元の段階を説明しています。

よくある質問

tspanは各行の改行位置を自動で決めますか? このひな形は単語群を計測して判断し、採用した各行に一つの位置指定tspanを作ります。SVG 2のCSSベースの自動折り返しはテストしていません。

ラベルより長い単語はどうなりますか? 処理はoverlong-wordを返し、ラベルを出力しません。黙って切り抜くのではなく、領域を広げる、文言を短くする、別途テストした分割方針を用意する、のいずれかを行ってください。

maxLinesは残りの単語を削除しますか? 削除しません。行数が多すぎる場合、この方針は結果を拒否し、必要な行数を報告して、tspanをゼロにします。アプリケーションがその状態を処理する必要があります。

別のフォントでも書き出した行は同じに見えますか? 明示的な単語と位置は残りますが、ファイルはフォントを埋め込まず、グリフをアウトライン化しません。実際の利用先で、使えるフォントと文字の見た目を確認してください。

出典

  1. MDNのtspan要素 — 位置指定した部分テキストと、行オフセットの属性。
  2. W3CのSVG 2テキスト — 明示的な配置と、別の自動折り返しモデル。
  3. MDN getComputedTextLength — 実際のSVGテキストの計測。
  4. MDN FontFaceSet.ready — フォント読み込みとレイアウトの待機。
  5. MDN textContent — マークアップではなくプレーンテキストの挿入。
  6. MDN XMLSerializer — レイアウト済みSVGマークアップの保存。

自分のラスターイラストには、編集可能なクリップアートを準備し、図形を確認してマスターを保管します。文言を別に追加し、納品前に保存したSVGで採用された行と見える文字を確認してください。

ブログのその他の記事

PerfectVector

編集しやすい
よりきれいなSVGから始めましょう

クレジットカード不要