SVG не предоставляет встроенного механизма автоматического переноса
текста внутри элемента <text>. В отличие от HTML, где
браузер самостоятельно рассчитывает переносы строк по ширине контейнера,
SVG воспринимает текст как одну непрерывную последовательность
символов.
Это создаёт несколько проблем:
В D3.js эта проблема возникает особенно часто при:
Существует два основных подхода:
foreignObject;<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:divSVG принадлежит пространству имён 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")
позволяет переносить даже длинные слова.
Внутри 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 особенно удобным для:
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 работают значительно лучше, однако проблемы всё ещё встречаются:
Большая проблема — HTML внутри SVG часто теряется при:
Например:
const svgString = new XMLSerializer()
.serializeToString(svg.node());
HTML внутри foreignObject может сериализоваться
некорректно.
Если SVG рендерится в Canvas:
ctx.drawImage(svgImage, 0, 0);
то foreignObject может:
Особенно часто это происходит при внешних CSS или шрифтах.
HTML внутри SVG запускает полноценный HTML layout engine.
При большом количестве элементов это может быть дорого:
Особенно заметно на:
<tspan>SVG поддерживает элемент <tspan>, который
позволяет разбивать текст на строки вручную.
Каждая строка становится отдельным tspan.
Пример:
<text>
<tspan x="0" dy="0">Первая строка</tspan>
<tspan x="0" dy="20">Вторая строка</tspan>
</text>
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Метод измеряет фактическую визуальную ширину текста с учётом:
Это значительно точнее, чем:
text.length * fontSize
HTML-layout отсутствует.
Поэтому SVG не умеет:
Каждый перенос приходится рассчитывать вручную.
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;
и затем смещать первую строку вверх.
Например:
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Преимущества:
Недостатки:
<tspan>Преимущества:
Недостатки:
foreignObjectПодход подходит для:
Особенно полезен, когда нужен:
<tspan>Подход предпочтителен для:
Во многих проектах используется комбинация:
foreignObject для интерактивного UI;<tspan> для экспортируемой графики.Например:
| Элемент | Технология |
|---|---|
| Tooltip | foreignObject |
| Подписи осей | tspan |
| Карточки UI | foreignObject |
| Экспорт PNG | tspan |
getComputedTextLength() вызывает browser layout
recalculation.
Тысячи вызовов могут тормозить приложение.
const cache = new Map();
Лучше:
Иногда измерения выполняют во временном скрытом SVG.
При изменении размеров контейнера перенос нужно пересчитывать:
window.addEventListener("resize", redraw);
Поскольку SVG не умеет автоматически перевычислять layout.
Если текст содержит:
Первая строка\nВторая строка
необходимо сначала разделить его:
text.split("\n")
а затем уже применять word wrapping отдельно к каждой строке.
dydy задаёт вертикальное смещение относительно предыдущего
tspan.
Пример:
.attr("dy", "1.2em")
Использование em удобно, потому что интервал
масштабируется вместе со шрифтом.
Даже одинаковое количество символов может иметь разную ширину:
WWWWWW
iiiiii
Поэтому перенос всегда должен основываться на реальном измерении текста, а не на количестве символов.
textLengthSVG поддерживает:
.attr("textLength", 150)
Но это не перенос текста.
SVG просто растягивает или сжимает символы.
Из-за этого ухудшается читаемость.
Иногда необходимо показывать только несколько строк:
Первая строка
Вторая строка
...
После достижения лимита:
if (lineNumber >= maxLines)
последняя строка обрезается:
line += "...";
SVG не поддерживает:
text-overflow: ellipsis;
Поэтому многоточие реализуется вручную.
Необходимо учитывать:
Некоторые языки:
не используют пробелы между словами.
Простой алгоритм:
split(/\s+/)
для них не подходит.
Intl.SegmenterСовременный Javascript предоставляет:
Intl.Segmenter
Пример:
const segmenter = new Intl.Segmenter("ja", {
granularity: "word"
});
Это позволяет корректно сегментировать текст для разных языков.
Практика показывает:
<tspan>;foreignObject;foreignObject.