Обёртка длинного текста в SVG: foreignObject vs ручной перенос

SVG не предоставляет встроенного механизма автоматического переноса текста внутри элемента <text>. В отличие от HTML, где браузер самостоятельно рассчитывает переносы строк по ширине контейнера, SVG воспринимает текст как одну непрерывную последовательность символов.

Это создаёт несколько проблем:

  • длинные подписи выходят за пределы диаграммы;
  • текст накладывается на другие элементы;
  • адаптивность становится сложной;
  • невозможно ограничить ширину блока стандартными средствами SVG.

В D3.js эта проблема возникает особенно часто при:

  • построении графиков;
  • создании tooltip;
  • визуализации узлов графа;
  • генерации подписей осей;
  • отображении описаний внутри прямоугольников и карточек.

Существует два основных подхода:

  1. использование foreignObject;
  2. ручной перенос строк через SVG-элементы <tspan>.

Использование foreignObject

Принцип работы

Элемент <foreignObject> позволяет встраивать HTML внутрь SVG.

Это означает, что вместо ограниченного SVG-текста можно использовать полноценный HTML-блок с CSS, flexbox, автоматическим переносом строк и любыми HTML-элементами.

Пример структуры:

<svg>
  <foreignObject>
    <div>Длинный текст...</div>
  </foreignObject>
</svg>

Внутри foreignObject браузер начинает использовать обычный HTML-рендеринг.


Создание foreignObject в D3.js

Пример:

const svg = d3.select("svg");

svg.append("foreignObject")
    .attr("x", 50)
    .attr("y", 40)
    .attr("width", 200)
    .attr("height", 120)
    .append("xhtml:div")
    .style("font-size", "14px")
    .style("line-height", "1.4")
    .style("word-wrap", "break-word")
    .html(`
        Очень длинный текст,
        который автоматически переносится
        средствами HTML и CSS.
    `);

Почему используется xhtml:div

SVG принадлежит пространству имён SVG XML, а HTML — пространству XHTML.

Поэтому D3 требует:

.append("xhtml:div")

Без префикса xhtml: некоторые браузеры могут некорректно интерпретировать элемент.


Автоматический перенос текста

Главное преимущество foreignObject — браузер сам рассчитывает переносы.

Достаточно указать ширину:

.attr("width", 180)

После этого HTML-блок начинает вести себя как обычный контейнер.

CSS:

.style("overflow-wrap", "break-word")

или:

.style("word-break", "break-word")

позволяет переносить даже длинные слова.


Использование обычного CSS

Внутри foreignObject доступны почти все CSS-возможности:

.style("display", "flex")
.style("align-items", "center")
.style("padding", "10px")
.style("border", "1px solid #999")
.style("background", "#fff")
.style("border-radius", "8px")

Это делает foreignObject особенно удобным для:

  • карточек;
  • tooltip;
  • описаний;
  • popup-блоков;
  • сложных подписей.

Пример карточки

svg.append("foreignObject")
    .attr("x", 20)
    .attr("y", 20)
    .attr("width", 260)
    .attr("height", 140)
    .append("xhtml:div")
    .style("background", "#f5f5f5")
    .style("padding", "12px")
    .style("border-radius", "10px")
    .style("font-family", "sans-serif")
    .html(`
        <h3>Продажи</h3>
        <p>
            Длинное описание автоматически
            переносится без дополнительной
            логики в Javascript.
        </p>
    `);

Ограничения foreignObject

Проблемы совместимости

Исторически foreignObject плохо поддерживался браузерами.

Современные версии Chrome, Firefox и Safari работают значительно лучше, однако проблемы всё ещё встречаются:

  • экспорт SVG;
  • PDF-генерация;
  • серверный рендеринг;
  • старые браузеры;
  • некоторые библиотеки snapshot/rendering.

Проблемы при экспорте SVG

Большая проблема — HTML внутри SVG часто теряется при:

  • сохранении SVG-файла;
  • конвертации SVG в PNG;
  • использовании canvas;
  • работе с библиотеками экспорта.

Например:

const svgString = new XMLSerializer()
    .serializeToString(svg.node());

HTML внутри foreignObject может сериализоваться некорректно.


Ограничения при работе с Canvas

Если SVG рендерится в Canvas:

ctx.drawImage(svgImage, 0, 0);

то foreignObject может:

  • не отображаться;
  • отображаться частично;
  • вызывать ошибки безопасности.

Особенно часто это происходит при внешних CSS или шрифтах.


Производительность

HTML внутри SVG запускает полноценный HTML layout engine.

При большом количестве элементов это может быть дорого:

  • reflow;
  • repaint;
  • layout calculations.

Особенно заметно на:

  • интерактивных графах;
  • больших dashboard;
  • force-directed graphs.

Ручной перенос текста через <tspan>

Основная идея

SVG поддерживает элемент <tspan>, который позволяет разбивать текст на строки вручную.

Каждая строка становится отдельным tspan.

Пример:

<text>
    <tspan x="0" dy="0">Первая строка</tspan>
    <tspan x="0" dy="20">Вторая строка</tspan>
</text>

Создание многострочного текста в D3.js

const text = svg.append("text")
    .attr("x", 40)
    .attr("y", 40);

text.append("tspan")
    .attr("x", 40)
    .attr("dy", 0)
    .text("Первая строка");

text.append("tspan")
    .attr("x", 40)
    .attr("dy", 20)
    .text("Вторая строка");

Алгоритм ручного переноса

Разбиение текста на слова

Сначала строка разбивается:

const words = text.split(/\s+/);

Накопление строки

Алгоритм постепенно добавляет слова:

line.push(word);

Проверка ширины

SVG предоставляет метод:

getComputedTextLength()

Он позволяет измерять фактическую длину текста.

Пример:

tspan.node().getComputedTextLength()

Если строка слишком длинная

Когда ширина превышает лимит:

if (length > width)

последнее слово переносится на новую строку.


Полная функция переноса текста

function wrapText(textSelection, width) {

    textSelection.each(function() {

        const text = d3.select(this);
        const words = text.text().split(/\s+/).reverse();

        let word;
        let line = [];
        let lineNumber = 0;

        const lineHeight = 1.2;

        const y = text.attr("y");
        const x = text.attr("x");

        let tspan = text.text(null)
            .append("tspan")
            .attr("x", x)
            .attr("y", y);

        while (word = words.pop()) {

            line.push(word);

            tspan.text(line.join(" "));

            if (tspan.node().getComputedTextLength() > width) {

                line.pop();

                tspan.text(line.join(" "));

                line = [word];

                tspan = text.append("tspan")
                    .attr("x", x)
                    .attr("y", y)
                    .attr("dy", `${++lineNumber * lineHeight}em`)
                    .text(word);
            }
        }
    });
}

Использование функции

const label = svg.append("text")
    .attr("x", 50)
    .attr("y", 40)
    .text(`
        Очень длинный текст,
        который должен переноситься
        внутри SVG-элемента.
    `);

wrapText(label, 180);

Как работает getComputedTextLength

Метод измеряет фактическую визуальную ширину текста с учётом:

  • шрифта;
  • размера;
  • kerning;
  • spacing;
  • browser rendering.

Это значительно точнее, чем:

text.length * fontSize

Особенности SVG-переноса

SVG не знает ширину контейнера

HTML-layout отсутствует.

Поэтому SVG не умеет:

  • flow layout;
  • block layout;
  • inline wrapping;
  • auto line breaks.

Каждый перенос приходится рассчитывать вручную.


Высота блока рассчитывается самостоятельно

SVG не увеличивает высоту автоматически.

После переноса строк приходится самостоятельно вычислять:

lineCount * lineHeight

Вертикальное выравнивание

После переноса текст может «уехать» вниз.

Поэтому часто используется:

dominant-baseline

или ручная коррекция координат.


Улучшенная версия функции переноса

Поддержка длинных слов

Стандартный алгоритм не умеет переносить:

SuperVeryLongWordWithoutSpaces

Для решения приходится разбивать слово посимвольно.


Пример обработки длинных слов

function breakWord(word, maxLength) {

    const parts = [];

    for (let i = 0; i < word.length; i += maxLength) {
        parts.push(word.slice(i, i + maxLength));
    }

    return parts;
}

Центрирование многострочного текста

Горизонтальное центрирование

.attr("text-anchor", "middle")

Но каждый tspan тоже должен иметь:

.attr("x", centerX)

Вертикальное центрирование

Нужно вычислять количество строк:

const offset = (lineCount - 1) * lineHeight / 2;

и затем смещать первую строку вверх.


Перенос текста внутри прямоугольника

Частая задача в D3.js

Например:

  • treemap;
  • sankey;
  • network nodes;
  • org chart.

Пример

svg.append("rect")
    .attr("x", 20)
    .attr("y", 20)
    .attr("width", 220)
    .attr("height", 100)
    .attr("fill", "#ddd");

const label = svg.append("text")
    .attr("x", 30)
    .attr("y", 40)
    .text("Очень длинный текст внутри прямоугольника");

wrapText(label, 180);

Сравнение подходов

foreignObject

Преимущества:

  • автоматический перенос;
  • полноценный HTML;
  • CSS;
  • flexbox;
  • простая стилизация;
  • удобство разработки.

Недостатки:

  • проблемы совместимости;
  • сложный экспорт;
  • возможные проблемы производительности;
  • нестабильность в canvas/export pipeline.

<tspan>

Преимущества:

  • нативный SVG;
  • высокая совместимость;
  • корректный экспорт;
  • стабильный рендеринг;
  • лучше подходит для графиков.

Недостатки:

  • сложная логика;
  • ручные вычисления;
  • необходимость измерений;
  • отсутствие auto layout.

Когда использовать foreignObject

Подход подходит для:

  • tooltip;
  • popup;
  • карточек;
  • rich text;
  • HTML-разметки;
  • dashboard-интерфейсов.

Особенно полезен, когда нужен:

  • CSS;
  • адаптивный layout;
  • изображения;
  • кнопки;
  • сложная типографика.

Когда использовать <tspan>

Подход предпочтителен для:

  • production SVG;
  • экспорта;
  • генерации изображений;
  • PDF;
  • графиков;
  • подписей осей;
  • canvas rendering;
  • серверной генерации SVG.

Гибридный подход

Во многих проектах используется комбинация:

  • foreignObject для интерактивного UI;
  • <tspan> для экспортируемой графики.

Например:

Элемент Технология
Tooltip foreignObject
Подписи осей tspan
Карточки UI foreignObject
Экспорт PNG tspan

Производительность при большом количестве текста

Проблема измерений

getComputedTextLength() вызывает browser layout recalculation.

Тысячи вызовов могут тормозить приложение.


Оптимизация

Кэширование ширин

const cache = new Map();

Минимизация reflow

Лучше:

  • сначала создать элементы;
  • потом измерять;
  • затем обновлять DOM пакетно.

Offscreen SVG

Иногда измерения выполняют во временном скрытом SVG.


Адаптивный перенос текста

При изменении размеров контейнера перенос нужно пересчитывать:

window.addEventListener("resize", redraw);

Поскольку SVG не умеет автоматически перевычислять layout.


Поддержка переносов строк из исходного текста

Если текст содержит:

Первая строка\nВторая строка

необходимо сначала разделить его:

text.split("\n")

а затем уже применять word wrapping отдельно к каждой строке.


Работа с dy

dy задаёт вертикальное смещение относительно предыдущего tspan.

Пример:

.attr("dy", "1.2em")

Использование em удобно, потому что интервал масштабируется вместе со шрифтом.


Влияние шрифта на перенос

Даже одинаковое количество символов может иметь разную ширину:

WWWWWW
iiiiii

Поэтому перенос всегда должен основываться на реальном измерении текста, а не на количестве символов.


Использование textLength

SVG поддерживает:

.attr("textLength", 150)

Но это не перенос текста.

SVG просто растягивает или сжимает символы.

Из-за этого ухудшается читаемость.


Ограничение количества строк

Иногда необходимо показывать только несколько строк:

Первая строка
Вторая строка
...

Реализация

После достижения лимита:

if (lineNumber >= maxLines)

последняя строка обрезается:

line += "...";

Ellipsis в SVG

SVG не поддерживает:

text-overflow: ellipsis;

Поэтому многоточие реализуется вручную.


Основные проблемы ручного переноса

Сложность типографики

Необходимо учитывать:

  • межстрочный интервал;
  • выравнивание;
  • padding;
  • clipping;
  • baseline;
  • unicode;
  • RTL-языки.

Международные тексты

Некоторые языки:

  • китайский;
  • японский;
  • тайский;

не используют пробелы между словами.

Простой алгоритм:

split(/\s+/)

для них не подходит.


Использование Intl.Segmenter

Современный Javascript предоставляет:

Intl.Segmenter

Пример:

const segmenter = new Intl.Segmenter("ja", {
    granularity: "word"
});

Это позволяет корректно сегментировать текст для разных языков.


Выбор подхода в реальных проектах

Практика показывает:

  • для визуализаций лучше подходит <tspan>;
  • для UI-компонентов — foreignObject;
  • для экспортируемых SVG почти всегда используется ручной перенос;
  • для сложного rich text — HTML внутри foreignObject.