В Word-by-word Aligner каждое слово показано боксом, который можно кликнуть и связать с его переводом. Превращение строки текста в эти боксы и есть токенизация, и именно на этом шаге быстрое решение рассыпается.
Очевидный вариант text.split(' ') ломается на первом же настоящем предложении. «world!» приходит с приклеенным восклицательным знаком. «don’t» либо остаётся целым, либо теряет апостроф, смотря как вы срезаете пунктуацию. Устойчивое выражение вроде «New York» делится на два бокса там, где должен быть один. А тот, кто работает с составным словом или с письменностью, которую сам сегментирует иначе, никак не может сказать инструменту, что считать словом. На все четыре случая мы и наткнулись, поэтому токенизатор вырос в небольшую функцию с несколькими настройками, а не в однострочник.
Что такое токен
Токен состоит из текста и одного флага:
export interface Token {
id: TokenId;
text: string;
/** No visible space before this token in preview/editor rows. */
joinLeft?: boolean;
}
Флаг joinLeft решает вопрос пробелов. Обычное слово рисуется с пробелом перед собой. Пунктуация и части разбитого составного слова рисуются вплотную к слову слева. Эту разницу несёт один булев флаг, и раскладка ряда остаётся компактной.
Два прохода: сначала деление, потом пунктуация
Главный цикл проходит строку один раз. Пробел завершает текущее слово. Заданный символ-разделитель тоже завершает его и помечает следующий кусок как приклеенный слева. Всё остальное накапливается:
for (const ch of cleaned) {
if (/\s/u.test(ch)) {
pushSegment();
continue;
}
if (splitSet.has(ch)) {
pushSegment();
nextJoinLeft = true;
continue;
}
cur += ch;
}
pushSegment();
Каждый сегмент затем проходит второй проход для пунктуации, потому что правила пунктуации отличаются от правил границы слова. Благодаря этому разделению оба прохода остаются простыми для чтения.
Проблема апострофа
На делении по пунктуации страдают сокращения. Срежьте каждый знак пунктуации, и «don’t» превратится в «don» и «t». Правило, которое это чинит, узкое: апостроф остаётся внутри слова, когда стоит между двумя буквами.
function apostropheBetweenLetters(ch, prev, next): boolean {
return Boolean(
(ch === "'" || ch === '’') && prev && next && /\p{L}/u.test(prev) && /\p{L}/u.test(next)
);
}
function shouldSplitPunctuation(ch, prev, next, punctuationSplitSet): boolean {
const isPunct = punctuationSplitSet ? punctuationSplitSet.has(ch) : PUNCT_CLASS.test(ch);
if (!isPunct) return false;
if (apostropheBetweenLetters(ch, prev, next)) return false;
return true;
}
Считаются и прямой, и типографский апостроф, а обе соседние буквы проверяются классом букв Unicode \p{L}, поэтому правило работает в разных письменностях, а не только в английском. «don’t» остаётся одним токеном; «world!» становится «world» и приклеенным «!».
По умолчанию деление по пунктуации использует класс пунктуации Unicode \p{P}. Тот, кому нужен контроль точнее, может передать явный набор символов, и тогда делятся только они.
Устойчивые выражения как один бокс
Обратная задача: два отдельных слова, которые должны выравниваться как одно целое. Это решает символ-склейка. Человек печатает выражение с маркером склейки, а токенизатор превращает маркер обратно в пробел внутри одного токена:
function expandMerge(segment: string, mergeChar: string): string {
if (!mergeChar || !segment.includes(mergeChar)) return segment;
return segment.split(mergeChar).join(' ');
}
С + в роли символа-склейки «New+York» становится одним боксом «New York», который связывается как одно слово. Та же настройка, прочитанная наоборот, позволяет символу-разделителю разбить составное вроде «mother-in-law» на отдельные боксы, которые всё равно стоят вплотную.

Держим идентификаторы токенов стабильными при правках
Есть ловушка, которая всплывает только когда связи уже существуют. Связь хранит идентификаторы двух слов, которые она соединяет. Токенизация идёт заново на каждое нажатие клавиши, и первая версия каждый раз выдавала новые идентификаторы, а это тихо ломало существующие связи на каждой правке.
Решение в том, чтобы сверять новые токены со старыми по позиции и переиспользовать идентификатор, когда слот на месте:
export function reconcile(prev, nextTokens, lineId): Token[] {
return nextTokens.map((next, i) => {
const old = prev[i];
if (old) return { ...old, text: next.text, joinLeft: next.joinLeft };
return { id: `${lineId}-${i}`, text: next.text, joinLeft: next.joinLeft };
});
}
Правка слова сохраняет его идентификатор, и связи держатся. Добавление или удаление слов сдвигает идентификаторы после изменения, и приложение отсекает любую связь, которая больше не указывает на реальную пару. В итоге набор текста в середине строки не стирает работу выше.
Выводы
Токенизация выглядит тривиальной, пока пунктуация, сокращения и устойчивые выражения не появляются разом. Почти любой случай закрыли несколько настроек: символы-разделители, маркер склейки и режим пунктуации с одним исключением для апострофа между буквами. Что бы в итоге ни делило текст, стабильность идентификаторов это та часть, которую мы бы больше не пропустили, ведь именно она позволяет всему привязанному к токену пережить правку.
Word-by-word Aligner бесплатен и работает в браузере на aligner.tinygods.dev. Код открыт (MIT): github.com/tinygodsdev/bitext-word-alignment; токенизатор лежит в src/lib/domain/tokens.ts.