Во время работы над 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.