25 июня 2026 г.

Как рисовать линии-коннекторы между словами в тексте с переносами.

Дуги-коннекторы между словами английского предложения и его французского перевода

Во время работы над Word-by-word Aligner мы встретили задачу про раскладку, которую стоит записать. Инструмент ставит предложение над его переводом и рисует дуги от каждого слова к его паре в строке ниже. Текст остаётся обычным HTML, поэтому браузер сам переносит его, меняет размер и выбирает места разрыва строк. Коннекторы должны находить эти слова и оставаться привязанными к ним сквозь всё это.

Наивный путь: расставить линии вручную и всё пересчитывать самому, а значит постоянно бороться с браузером. Сработал другой подход: отдать раскладку браузеру и добавить сверху один шаг измерения.

Текст и линии в разных слоях

Слова рендерятся как обычный HTML во флекс-рядах, по ряду на строку текста. Коннекторы живут в отдельном SVG-слое поверх того же бокса. SVG не знает, где находится каждое слово, поэтому сначала опрашивается DOM.

У каждого бокса со словом есть атрибут data-token-id. После того как браузер разложил текст, эти боксы обходятся, прямоугольник каждого читается через getBoundingClientRect и переводится в координаты относительно контейнера:

rootEl.querySelectorAll<HTMLElement>('.token-row [data-token-id]').forEach((el) => {
  const id = el.dataset.tokenId;
  if (!id) return;
  const b = el.getBoundingClientRect();
  const x = b.left - ro.left;
  const y = b.top - ro.top;
  tokenLayout[id] = {
    cx: x + b.width / 2,
    cy: y + b.height / 2,
    x, y, w: b.width, h: b.height
  };
});

Запрос ограничен .token-row неслучайно. Другие части редактора (например, поповер настроек) монтируются внутри того же корня и могут переиспользовать data-token-id. Без ограничения их боксы перезаписали бы позиции настоящих слов.

Привязка дуги за пределами боксов

Когда каждое слово сведено к центру и прямоугольнику, коннектор превращается в две точки и путь между ними. Концы ставятся на несколько пикселей за пределами бокса, чтобы штрих не задевал выносные элементы и высокие буквы. Функция также проверяет, какое слово выше визуально, ведь сложенные ряды можно переставить:

const PAD = 8;

export function linkEndpoints(pUpper: TokenLayout, pLower: TokenLayout) {
  const upperIsAbove = pUpper.cy <= pLower.cy;
  if (upperIsAbove) {
    return { x1: pUpper.cx, y1: pUpper.y + pUpper.h + PAD, x2: pLower.cx, y2: pLower.y - PAD };
  }
  return { x1: pUpper.cx, y1: pUpper.y - PAD, x2: pLower.cx, y2: pLower.y + pLower.h + PAD };
}

Сама дуга задаётся кубической кривой Безье, у которой обе контрольные точки лежат на горизонтальной середине между словами. Из-за этого линия выходит из одного слова вертикально и так же вертикально приходит к другому, как бы далеко они ни стояли по горизонтали:

export function linkPathD(x1: number, y1: number, x2: number, y2: number, style: 'straight' | 'curved') {
  if (style === 'straight') return `M ${x1} ${y1} L ${x2} ${y2}`;
  const ym = (y1 + y2) / 2;
  return `M ${x1} ${y1} C ${x1} ${ym} ${x2} ${ym} ${x2} ${y2}`;
}

Пересчёт, когда раскладка сдвигается

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

ResizeObserver закрывает изменения размера. API document.fonts закрывает загрузку шрифтов через промис ready и событие loadingdone. Измерение запускается заново на любом из них:

const ro = new ResizeObserver(() => remeasure());
ro.observe(rootEl);
remeasure();

if (browser && document.fonts) {
  void document.fonts.ready.then(() => { if (!cancelled) remeasure(); });
  document.fonts.addEventListener('loadingdone', remeasure);
}

В шаге remeasure пряталась тонкая ошибка. Чтение позиций в том же кадре, что и изменение раскладки, даёт устаревшие числа, потому что браузер ещё не применил перерасчёт. Ожидание двух кадров анимации даёт раскладке устояться до измерения:

function remeasure() {
  requestAnimationFrame(() => {
    requestAnimationFrame(() => measure());
  });
}

Часто хватает и одного кадра. Второй кадр закрывает случаи, когда не хватает, а стоит это несколько миллисекунд, которых глаз не замечает.

Зачем коннекторам пересекаться

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

Японский, китайский и английский, выровненные так, чтобы показать разный порядок слов, с пересекающимися коннекторами

Создание связей

Создание связи занимает два клика: закрепить слово, затем кликнуть его пару в соседней строке. Модель остаётся компактной из-за одного правила. Связь возникает только между вертикально соседними строками. Когда вместе сложены исходная строка, глосса и строка транскрипции, клик между двумя несоседними рядами провёл бы коннектор прямо через ряд между ними, поэтому инструмент переносит закрепление и показывает подсказку:

const ia = lineIds.indexOf(cur.lineId);
const ib = lineIds.indexOf(lineId);
if (ia < 0 || ib < 0 || Math.abs(ia - ib) !== 1) {
  this.adjacencyHint = true;
  this.pending = { lineId, tokenId };
  return;
}

Выводы

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

Word-by-word Aligner бесплатен и работает в браузере на aligner.tinygods.dev. Код открыт (MIT): github.com/tinygodsdev/bitext-word-alignment; геометрия из этой статьи лежит в src/lib/domain/link-geometry.ts и src/lib/components/preview/AlignmentSvg.svelte.