Получение индексов границ

Работа со строками в JavaScript долгое время ограничивалась посимвольным перебором, регулярными выражениями и методами split(), substring() или match(). Однако текст в современном мире значительно сложнее простого набора ASCII-символов:

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

Для решения этих задач в Intl API появился объект Intl.Segmenter, предназначенный для интеллектуального разбиения текста на сегменты.

Одной из ключевых возможностей сегментатора является получение индексов границ сегментов.


Объект Intl.Segmenter

Создание сегментатора:

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

Параметры:

Параметр Назначение
locale локаль
granularity уровень сегментации

Возможные значения granularity:

Значение Описание
"grapheme" отдельные графемы
"word" слова
"sentence" предложения

Метод segment()

Основной метод:

segmenter.segment(text)

Возвращает специальный объект-итератор сегментов.

Пример:

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

const segments = segmenter.segment("Привет мир");

Структура сегмента

Каждый сегмент содержит несколько важных свойств:

{
    segment: "Привет",
    index: 0,
    input: "Привет мир",
    isWordLike: true
}

Описание свойств:

Свойство Назначение
segment текст сегмента
index индекс начала сегмента
input исходная строка
isWordLike является ли сегмент словом

Главный интерес представляет именно свойство index.


Индекс начала сегмента

Свойство index содержит позицию начала сегмента в исходной строке.

Пример:

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

const text = "Привет мир";

for (const item of segmenter.segment(text)) {
    console.log(item.segment, item.index);
}

Результат:

Привет 0
  7
мир 8

Пробел также является сегментом.


Получение всех индексов границ

Часто требуется получить полный список границ текста.

Пример:

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

const text = "JavaScript Intl API";

const indexes = [];

for (const item of segmenter.segment(text)) {
    indexes.push(item.index);
}

console.log(indexes);

Результат:

[0, 10, 11, 15, 16]

Индексы при сегментации графем

Графема — визуальный символ, который может состоять из нескольких Unicode-кодов.

Пример с эмодзи:

const text = "???";

console.log(text.length);

Результат:

6

Хотя визуально отображаются только два символа.

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

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

for (const item of segmenter.segment(text)) {
    console.log(item.segment, item.index);
}

Результат:

?? 0
? 4

Индексы показывают реальные границы Unicode-последовательностей.


Почему обычная индексация работает неправильно

Стандартные строковые методы работают с UTF-16 кодовыми единицами.

Пример:

const text = "??";

console.log(text[0]);
console.log(text[1]);

Результат:

�
�

Причина:

  • эмодзи занимает несколько кодовых единиц;
  • отдельные части Unicode-последовательности не являются полноценными символами.

Intl.Segmenter корректно определяет пользовательские символы.


Итерация по индексам графем

Пример получения массива позиций:

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

const text = "A??B?";

const positions = [];

for (const item of segmenter.segment(text)) {
    positions.push(item.index);
}

console.log(positions);

Результат:

[0, 1, 5, 6]

Индексы слов

Сегментация слов особенно важна для языков без пробелов.

Русский язык

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

const text = "Привет, мир!";

for (const item of segmenter.segment(text)) {
    console.log(item);
}

Результат:

{ segment: 'Привет', index: 0, isWordLike: true }
{ segment: ',', index: 6, isWordLike: false }
{ segment: ' ', index: 7, isWordLike: false }
{ segment: 'мир', index: 8, isWordLike: true }
{ segment: '!', index: 11, isWordLike: false }

Фильтрация только слов

Обычно нужны только текстовые слова.

Пример:

const words = [];

for (const item of segmenter.segment(text)) {
    if (item.isWordLike) {
        words.push({
            word: item.segment,
            index: item.index
        });
    }
}

console.log(words);

Результат:

[
    { word: 'Привет', index: 0 },
    { word: 'мир', index: 8 }
]

Индексы предложений

Сегментация предложений:

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

const text = "Первое предложение. Второе предложение!";

for (const item of segmenter.segment(text)) {
    console.log(item.segment, item.index);
}

Результат:

Первое предложение. 0
 Второе предложение! 21

Навигация по тексту

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

Пример поиска начала слова:

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

const text = "JavaScript Segmenter API";

for (const item of segmenter.segment(text)) {
    console.log(
        `Слово "${item.segment}" начинается с ${item.index}`
    );
}

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

Intl.Segmenter особенно полезен в текстовых редакторах.

Пример получения диапазона слова:

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

const text = "Пример текста";

for (const item of segmenter.segment(text)) {
    const start = item.index;
    const end = start + item.segment.length;

    console.log(start, end);
}

Результат:

0 7
7 8
8 14

Метод containing()

Объект сегментов поддерживает метод:

segments.containing(index)

Он позволяет найти сегмент по позиции.

Пример:

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

const text = "JavaScript API";

const segments = segmenter.segment(text);

console.log(segments.containing(2));

Результат:

{
    segment: 'JavaScript',
    index: 0,
    isWordLike: true
}

Поиск сегмента по индексу

Позиция внутри слова:

console.log(segments.containing(5));

Результат:

{
    segment: 'JavaScript',
    index: 0
}

Позиция внутри пробела:

console.log(segments.containing(10));

Результат:

{
    segment: ' ',
    index: 10
}

Практическое применение containing()

Определение слова под курсором

function getWordAt(text, cursor) {
    const segmenter = new Intl.Segmenter("ru", {
        granularity: "word"
    });

    const segments = segmenter.segment(text);

    return segments.containing(cursor);
}

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

const result = getWordAt(
    "JavaScript Segmenter API",
    12
);

console.log(result.segment);

Результат:

Segmenter

Получение конечных индексов

Intl.Segmenter хранит только начало сегмента.

Конечный индекс вычисляется вручную:

const end = item.index + item.segment.length;

Пример:

for (const item of segmenter.segment(text)) {
    const start = item.index;
    const end = start + item.segment.length;

    console.log({
        segment: item.segment,
        start,
        end
    });
}

Построение диапазонов

Пример:

const ranges = [];

for (const item of segmenter.segment(text)) {
    ranges.push({
        start: item.index,
        end: item.index + item.segment.length
    });
}

console.log(ranges);

Работа с азиатскими языками

Для китайского и японского языков обычное разделение по пробелам невозможно.

Китайский язык

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

const text = "我喜欢JavaScript";

for (const item of segmenter.segment(text)) {
    console.log(item.segment, item.index);
}

Пример результата:

我 0
喜欢 1
JavaScript 3

Японский язык

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

const text = "私は猫です";

for (const item of segmenter.segment(text)) {
    console.log(item.segment, item.index);
}

Сравнение с split()

Использование split()

const words = text.split(" ");

Недостатки:

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

Преимущества Intl.Segmenter

Возможность split() Intl.Segmenter
Unicode-графемы Нет Да
Эмодзи Плохо Да
Китайский язык Нет Да
Японский язык Нет Да
Индексы сегментов Ограниченно Да
Поиск по позиции Нет Да

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

Создание сегментатора — сравнительно дорогая операция.

Нежелательно:

for (const text of texts) {
    const segmenter = new Intl.Segmenter("ru", {
        granularity: "word"
    });

    segmenter.segment(text);
}

Лучше:

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

for (const text of texts) {
    segmenter.segment(text);
}

Кэширование сегментаторов

Практика для крупных приложений:

const cache = new Map();

function getSegmenter(locale, granularity) {
    const key = `${locale}_${granularity}`;

    if (!cache.has(key)) {
        cache.set(
            key,
            new Intl.Segmenter(locale, {
                granularity
            })
        );
    }

    return cache.get(key);
}

Совместимость

Intl.Segmenter поддерживается:

  • современными версиями Chrome;
  • Firefox;
  • Edge;
  • Safari;
  • Node.js.

Проверка поддержки:

if (Intl.Segmenter) {
    console.log("Поддерживается");
}

Полный пример анализа текста

const text = "Привет ?? мир!";

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

for (const item of segmenter.segment(text)) {
    console.log({
        symbol: item.segment,
        index: item.index
    });
}

Результат:

{ symbol: 'П', index: 0 }
{ symbol: 'р', index: 1 }
{ symbol: 'и', index: 2 }
{ symbol: 'в', index: 3 }
{ symbol: 'е', index: 4 }
{ symbol: 'т', index: 5 }
{ symbol: ' ', index: 6 }
{ symbol: '??', index: 7 }
{ symbol: ' ', index: 11 }
{ symbol: 'м', index: 12 }
{ symbol: 'и', index: 13 }
{ symbol: 'р', index: 14 }
{ symbol: '!', index: 15 }