PerfectVector
Автор: Irene Kim

Перенос текста SVG: создайте измеренные строки с tspan

Переносите подписи SVG, измеряя слова настоящим шрифтом, затем создавайте tspan. Проверьте узкие подписи, отклоняйте слишком широкие слова и лишние строки, сохраняйте результат.

10 мин чтения
На этой странице

Чтобы переносить меняющуюся подпись SVG на явные строки, измеряйте возможные группы слов с getComputedTextLength(), затем создавайте один <tspan> на принятую строку. Сначала дождитесь шрифтов документа и выберите поведение, если слово шире подписи или результат превышает ограничение числа строк.

Стартовый пример ниже использует слова, разделённые пробельными символами, и отклоняет оба переполнения без частичной подписи. Он отделяет оригинальную иллюстрацию полого ромба от формулировки, поэтому изменение ширины подписи не перерисовывает графику.

Выберите правила переноса до размещения строк

Элемент tspan позволяет размещать части текста SVG. Наша процедура задаёт одинаковый x каждой строке и смещение dy последующим. Решение о разрыве строки принимает процедура, элемент хранит результат.

Это один явный подход к компоновке. Спецификация текста SVG 2 также определяет автоматический перенос через область содержимого и компоновку текста CSS. Эта статья не проверяет эти функции и не утверждает, что они отсутствуют во всех браузерах.

Для HTML-абзаца вне иллюстрации используйте обтекание плавающего силуэта SVG текстом через CSS. Такая компоновка следует контуру изображения, сохраняя формулировку обычным HTML, а не позиционированными строками подписи SVG.

Для этого небольшого примера английской подписи правила таковы:

  • Свернуть пробельные символы и пробовать слова в исходном порядке с одним пробелом между ними.
  • Сохранять кандидат строки, только пока измеренная длина укладывается в доступную ширину.
  • Отклонять слово, которое не помещается само по себе, не придумывая точку переноса с дефисом.
  • Отклонять весь результат, если нужно больше maxLines, не отбрасывая последние слова незаметно.

Ограничение строк — решение компоновки. Выбирайте его вместе с начальной базовой линией, межстрочным интервалом и доступной высотой. Обёртка не рассчитывает вертикальный прямоугольник содержимого за вас.

Меняйте ширину подписи, сохраняйте графику
Концептуальное сравнение иллюстрации полого ромба рядом с длинными строками широкой подписи и короткими строками узкой подписи
Иллюстрация: более узкой подписи может понадобиться больше строк. Абстрактные полосы и пунктирные рамки — концептуальные подсказки; реальные формулировки, измерения и состояния отклонения ниже получены в независимом браузерном примере.

Измеряйте шрифтом, который нарисует подпись

getComputedTextLength() измеряет вычисленную длину текста SVG. Пример клонирует текстовый элемент подписи как скрытый измерительный элемент внутри того же присоединённого SVG, затем удаляет его после планирования строк. Оба используют явный Arial, sans-serif размером 22 единицы.

Код ждёт 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, включая отклонения. Строки добавляются только после проверок ширины слов и максимального числа строк. Процедура сначала очищает предыдущую подпись, поэтому отклонённое обновление не оставляет старый или частичный текст; обрабатывайте возвращённый статус в интерфейсе приложения.

Что дали шесть оригинальных случаев

Мы выполнили этот точный пример в Chrome, где шрифты документа сообщали loaded. Длины ниже — длины текста 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. Каждая принятая строка по измерению помещалась в выбранную ширину. Случаи отклонения оставляли иллюстрацию видимой без подписи, что упрощало проверку состояния ошибки.

Все шесть сериализованных файлов сохранили одинаковый оригинальный контур ромба, один собственный текстовый элемент и отсутствие image или foreignObject. Принятые случаи содержали соответственно два, четыре, три и один tspan; оба отклонённых — ноль. Мы также снова открыли шесть сериализованных SVG как изображения в том же браузере: все загрузились в 360×240. Это проверка данных файлов и браузера, а не каждого редактора или средства отрисовки.

Сохраните скомпонованный SVG, затем проверьте передачу

XMLSerializer.serializeToString() создаёт сериализованную разметку в отчёте. Сохраните строку serialized успешного случая как файл .svg. В файле уже есть явные строки; эта процедура JavaScript для их воссоздания не нужна.

Слова всё ещё остаются живым текстом. Сохранённый файл хранит запрос font-family, а не файл шрифта или контуры глифов. Целевая среда с другим шрифтом может изменить надпись, хотя строки и позиции сохраняются. Держите исходную формулировку и входы компоновки рядом с экспортированной копией и проверяйте реальную среду перед передачей. Для фиксированной формы букв создайте передаваемую копию с контурами в подходящем редакторе, сохранив живой текст в мастере.

Руководство по тексту foreignObject описывает HTML-подписи, исчезающие в другом средстве отрисовки. Этот пример сразу создаёт собственный текст SVG. Руководство по textLength описывает вписывание одной строки изменением длины — другое решение, чем добавление измеренных строк. Используйте руководство по вертикальному выравниванию, если нужно расположить всю подпись относительно окружающей графики.

Явно обозначайте ограничения обёртки

Разделение по пробельным символам — намеренно небольшой набор правил. Он сворачивает повторяющиеся пробелы, табуляцию и явные переводы строк и не обрабатывает языковые правила разрыва, переносы с дефисами, двунаправленные абзацы или форматированные фрагменты со смешанными стилями. Выбирайте другую систему компоновки либо расширяйте и проверяйте правила, если тексту нужны эти функции.

Проверка ширины также не гарантирует, что выступы декоративных глифов останутся внутри нарисованной рамки или что пять базовых линий поместятся в выбранную высоту. Проверяйте видимые буквы в передаваемом размере. Наш пример использует горизонтальные подписи, фиксированные атрибуты шрифта и шаг строки в 30 единиц; не переносите его числа на другую гарнитуру без повторного измерения.

Если иллюстрация существует только в PNG или JPG, а нужна редактируемая графика, подготовьте SVG-клипарт с PerfectVector, проверьте края и внутренние отверстия и храните графику отдельно от подписи. Наберите формулировку заново как собственный текст, выполните правила переноса и проверьте собранный экспорт. Векторизация готовит контуры иллюстрации; она не выбирает разрывы строк и не восстанавливает редактируемые слова из трассированных букв. Уже хорошие контуры можно оставить как есть. Руководство по векторизации объясняет этот этап восстановления исходника.

Частые вопросы

Решает ли 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,
который проще редактировать

Без банковской карты