Как определить, где можно разрывать текст по символам или словам?

Находите безопасные позиции для разрыва текста при обрезке, переносе и работе с курсором

Введение

Когда вы обрезаете текст, ставите курсор или обрабатываете клики в текстовом редакторе, нужно понимать, где заканчивается один символ и начинается другой, а также где начинаются и заканчиваются слова. Неправильный разрыв текста может разделить эмодзи, разорвать составные символы или некорректно разбить слова.

API JavaScript Intl.Segmenter предоставляет метод containing() для поиска текстового сегмента в любой позиции строки. Это позволяет узнать, какой символ или слово содержит определённый индекс, а также где начинается и заканчивается этот сегмент. Эта информация помогает находить безопасные точки разрыва, которые учитывают границы графемных кластеров и лингвистические границы слов для всех языков.

В этой статье объясняется, почему нельзя разрывать текст в произвольных местах, как находить границы текста с помощью Intl.Segmenter и как использовать эту информацию для обрезки, позиционирования курсора и выделения текста.

Почему нельзя разрывать текст в любом месте

Строки в JavaScript состоят из кодовых единиц, а не из полноценных символов. Один эмодзи, буква с акцентом или флаг могут занимать несколько кодовых единиц. Если разрезать строку по произвольному индексу, можно случайно разделить символ посередине.

Рассмотрим пример:

const text = "Hello 👨‍👩‍👧‍👦 world";
const truncated = text.slice(0, 10);
console.log(truncated); // "Hello 👨‍�"

Эмодзи «семья» занимает 11 кодовых единиц. Если разрезать на позиции 10, эмодзи будет повреждён, и появится символ-заполнитель.

Если разрывать слова в неправильных местах, получаются фрагменты, которые не соответствуют ожиданиям пользователя:

const text = "Hello world";
const fragment = text.slice(0, 7);
console.log(fragment); // "Hello w"

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

Поиск текстового сегмента по определённой позиции

Метод containing() возвращает информацию о текстовом сегменте, который включает определённый индекс:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello 👋🏽";
const segments = segmenter.segment(text);

const segment = segments.containing(6);
console.log(segment);
// { segment: "👋🏽", index: 6, input: "Hello 👋🏽" }

Эмодзи на позиции 6 занимает четыре кодовых единицы (с 6 по 9 индекс). Метод containing() возвращает:

  • segment: вся графемная группа в виде строки
  • index: где этот сегмент начинается в исходной строке
  • input: ссылка на исходную строку

Это говорит о том, что позиция 6 находится внутри эмодзи, эмодзи начинается с индекса 6, а полный символ — «👋🏽».

Поиск безопасных точек для обрезки текста

Чтобы обрезать текст, не разрывая символы, найдите границу графемы перед нужной позицией:

function truncateAtPosition(text, maxIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(maxIndex);

  // Truncate before this segment to avoid breaking it
  return text.slice(0, segment.index);
}

truncateAtPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello " (stops before the emoji, not in the middle)

truncateAtPosition("café", 3);
// "caf" (stops before é)

Эта функция находит сегмент по целевой позиции и обрезает до него, чтобы не разделять графемный кластер.

Чтобы обрезать после сегмента, а не до него:

function truncateAfterPosition(text, minIndex) {
  const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(minIndex);
  const endIndex = segment.index + segment.segment.length;

  return text.slice(0, endIndex);
}

truncateAfterPosition("Hello 👨‍👩‍👧‍👦 world", 10);
// "Hello 👨‍👩‍👧‍👦 " (includes the complete emoji)

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

Поиск границ слов для переноса текста

При переносе текста по ширине строки нужно делать разрыв между словами, а не внутри слова. Используйте сегментацию по словам, чтобы найти границу слова перед нужной позицией:

function findWordBreakBefore(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  // If we're in a word, break before it
  if (segment.isWordLike) {
    return segment.index;
  }

  // If we're in whitespace or punctuation, break here
  return position;
}

const text = "Hello world";
findWordBreakBefore(text, 7, "en");
// 5 (the space before "world")

const textZh = "你好世界";
findWordBreakBefore(textZh, 6, "zh");
// 6 (the boundary before "世界")

Эта функция находит начало слова, в котором находится целевая позиция. Если позиция уже в пробеле, она возвращается без изменений.

Для переноса текста с учётом границ слов:

function wrapTextAtWidth(text, maxLength, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  const lines = [];
  let currentLine = "";

  for (const { segment, isWordLike } of segments) {
    const potentialLine = currentLine + segment;

    if (potentialLine.length <= maxLength) {
      currentLine = potentialLine;
    } else {
      if (currentLine) {
        lines.push(currentLine.trim());
      }
      currentLine = isWordLike ? segment : "";
    }
  }

  if (currentLine) {
    lines.push(currentLine.trim());
  }

  return lines;
}

wrapTextAtWidth("Hello world from JavaScript", 12, "en");
// ["Hello world", "from", "JavaScript"]

wrapTextAtWidth("你好世界欢迎使用", 6, "zh");
// ["你好世界", "欢迎使用"]

Эта функция разбивает текст на строки, учитывая границы слов и максимальную длину строки.

Определение слова, в котором находится курсор

В текстовых редакторах нужно знать, в каком слове находится курсор, чтобы реализовать такие функции, как выделение двойным щелчком, проверка орфографии или контекстное меню:

function getWordAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  if (!segment.isWordLike) {
    return null;
  }

  return {
    word: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world";
getWordAtPosition(text, 7, "en");
// { word: "world", start: 6, end: 11 }

getWordAtPosition(text, 5, "en");
// null (position 5 is the space, not a word)

Это возвращает слово под курсором вместе с его начальным и конечным индексами или null, если курсор не находится в слове.

Используйте это для реализации выделения текста двойным щелчком:

function selectWordAtPosition(text, position, locale) {
  const wordInfo = getWordAtPosition(text, position, locale);

  if (!wordInfo) {
    return { start: position, end: position };
  }

  return { start: wordInfo.start, end: wordInfo.end };
}

selectWordAtPosition("Hello world", 7, "en");
// { start: 6, end: 11 } (selects "world")

Определение границ предложения для навигации

Для навигации по документу или сегментации для синтеза речи определите, в каком предложении находится определённая позиция:

function getSentenceAtPosition(text, position, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "sentence" });
  const segments = segmenter.segment(text);

  const segment = segments.containing(position);

  return {
    sentence: segment.segment,
    start: segment.index,
    end: segment.index + segment.segment.length
  };
}

const text = "Hello world. How are you? Fine thanks.";
getSentenceAtPosition(text, 15, "en");
// { sentence: "How are you? ", start: 13, end: 26 }

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

Поиск следующей границы после позиции

Чтобы перейти вперёд на один графем, слово или предложение, проходите по сегментам, пока не найдёте тот, который начинается после текущей позиции:

function findNextBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > position) {
      return segment.index;
    }
  }

  return text.length;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findNextBoundary(text, 0, "grapheme", "en");
// 1 (boundary after "H")

findNextBoundary(text, 6, "grapheme", "en");
// 17 (boundary after the family emoji)

findNextBoundary(text, 0, "word", "en");
// 5 (boundary after "Hello")

Это определяет, где начинается следующий сегмент — безопасная позиция для перемещения курсора или обрезки текста.

Поиск предыдущей границы перед позицией

Чтобы вернуться назад на один графем, слово или предложение, найдите сегмент перед текущей позицией:

function findPreviousBoundary(text, position, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  let previousIndex = 0;

  for (const segment of segments) {
    if (segment.index >= position) {
      return previousIndex;
    }
    previousIndex = segment.index;
  }

  return previousIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
findPreviousBoundary(text, 17, "grapheme", "en");
// 6 (boundary before the family emoji)

findPreviousBoundary(text, 11, "word", "en");
// 6 (boundary before "world")

Это определяет, где начинается предыдущий сегмент — безопасная позиция для перемещения курсора назад.

Реализация перемещения курсора с учётом границ

Совмещайте поиск границ с позицией курсора, чтобы реализовать корректное перемещение курсора:

function moveCursorForward(text, cursorPosition, locale) {
  return findNextBoundary(text, cursorPosition, "grapheme", locale);
}

function moveCursorBackward(text, cursorPosition, locale) {
  return findPreviousBoundary(text, cursorPosition, "grapheme", locale);
}

function moveWordForward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  for (const segment of segments) {
    if (segment.index > cursorPosition && segment.isWordLike) {
      return segment.index;
    }
  }

  return text.length;
}

function moveWordBackward(text, cursorPosition, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let previousWordIndex = 0;

  for (const segment of segments) {
    if (segment.index >= cursorPosition) {
      return previousWordIndex;
    }
    if (segment.isWordLike) {
      previousWordIndex = segment.index;
    }
  }

  return previousWordIndex;
}

const text = "Hello 👨‍👩‍👧‍👦 world";
moveCursorForward(text, 6, "en");
// 17 (moves over the entire emoji)

moveWordForward(text, 0, "en");
// 6 (moves to the start of "world")

Эти функции реализуют стандартное перемещение курсора в текстовом редакторе с учётом границ графем и слов.

Поиск всех возможных мест переноса в тексте

Чтобы найти все позиции, где можно безопасно перенести текст, пройдитесь по всем сегментам и соберите их начальные индексы:

function getBreakOpportunities(text, granularity, locale) {
  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = Array.from(segmenter.segment(text));

  return segments.map(segment => segment.index);
}

const text = "Hello 👨‍👩‍👧‍👦 world";
getBreakOpportunities(text, "grapheme", "en");
// [0, 1, 2, 3, 4, 5, 6, 17, 18, 19, 20, 21, 22]

getBreakOpportunities(text, "word", "en");
// [0, 5, 6, 17, 18, 22]

В результате получится массив всех допустимых позиций переноса в тексте. Используйте это для реализации продвинутых функций разметки или анализа текста.

Обработка крайних случаев с границами

Если позиция находится в самом конце текста, containing() вернёт последний сегмент:

const segmenter = new Intl.Segmenter("en", { granularity: "grapheme" });
const text = "Hello";
const segments = segmenter.segment(text);

const segment = segments.containing(5);
console.log(segment);
// { segment: "o", index: 4, input: "Hello" }

Позиция в конце, поэтому возвращается последний графем.

Если позиция перед первым символом, containing() вернёт первый сегмент:

const segment = segments.containing(0);
console.log(segment);
// { segment: "H", index: 0, input: "Hello" }

Для пустых строк сегментов нет, поэтому вызов containing() для пустой строки вернёт undefined. Проверяйте, что строка не пуста, перед использованием containing():

function safeContaining(text, position, granularity, locale) {
  if (text.length === 0) {
    return null;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity });
  const segments = segmenter.segment(text);
  return segments.containing(position);
}

Выбор подходящей детализации границ

Используйте разную детализацию в зависимости от задачи:

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

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

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

Не используйте границы слов, если нужны границы символов, и не используйте границы графем, если нужны границы слов. У каждого варианта своё назначение.

Поддержка операций с границами в браузерах

API Intl.Segmenter и его метод containing() получили статус Baseline в апреле 2024 года. Текущие версии Chrome, Firefox, Safari и Edge поддерживают его. В старых браузерах поддержки нет.

Проверьте поддержку перед использованием:

if (typeof Intl.Segmenter !== "undefined") {
  const segmenter = new Intl.Segmenter("en", { granularity: "word" });
  const segments = segmenter.segment(text);
  const segment = segments.containing(position);
  // Use segment information
} else {
  // Fallback for older browsers
  // Use approximate boundaries based on string length
}

Для приложений, рассчитанных на старые браузеры, предусмотрите запасное поведение с использованием приблизительных границ или используйте polyfill, реализующий API Intl.Segmenter.

Частые ошибки при поиске границ

Не думайте, что любая кодовая единица — это допустимая точка разрыва. Многие позиции могут разделять кластеры графем или слова, что приводит к некорректным или неожиданным результатам.

Не используйте string.length для поиска конечной границы. Вместо этого используйте индекс последнего сегмента плюс его длину.

Не забывайте проверять isWordLike при работе с границами слов. Сегментатор возвращает не только слова, но и пробелы и знаки препинания.

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

Не вызывайте containing() многократно в производительно-важных операциях. Если нужно получить несколько границ, пройдитесь по сегментам один раз и постройте индекс.

Особенности производительности при работе с границами

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

class TextBoundaryCache {
  constructor(text, granularity, locale) {
    this.text = text;
    const segmenter = new Intl.Segmenter(locale, { granularity });
    this.segments = Array.from(segmenter.segment(text));
  }

  containing(position) {
    for (const segment of this.segments) {
      const end = segment.index + segment.segment.length;
      if (position >= segment.index && position < end) {
        return segment;
      }
    }
    return this.segments[this.segments.length - 1];
  }

  nextBoundary(position) {
    for (const segment of this.segments) {
      if (segment.index > position) {
        return segment.index;
      }
    }
    return this.text.length;
  }

  previousBoundary(position) {
    let previous = 0;
    for (const segment of this.segments) {
      if (segment.index >= position) {
        return previous;
      }
      previous = segment.index;
    }
    return previous;
  }
}

const cache = new TextBoundaryCache("Hello world", "grapheme", "en");
cache.containing(7);
cache.nextBoundary(7);
cache.previousBoundary(7);

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

Практический пример: обрезка текста с многоточием

Совместите поиск границ с обрезкой, чтобы создать функцию, которая обрезает текст по последнему целому слову до максимальной длины:

function truncateAtWordBoundary(text, maxLength, locale) {
  if (text.length <= maxLength) {
    return text;
  }

  const segmenter = new Intl.Segmenter(locale, { granularity: "word" });
  const segments = Array.from(segmenter.segment(text));

  let lastWordEnd = 0;

  for (const segment of segments) {
    const segmentEnd = segment.index + segment.segment.length;

    if (segmentEnd > maxLength) {
      break;
    }

    if (segment.isWordLike) {
      lastWordEnd = segmentEnd;
    }
  }

  if (lastWordEnd === 0) {
    return "";
  }

  return text.slice(0, lastWordEnd).trim() + "…";
}

truncateAtWordBoundary("Hello world from JavaScript", 15, "en");
// "Hello world…"

truncateAtWordBoundary("你好世界欢迎使用", 9, "zh");
// "你好世界…"

Эта функция находит последнее целое слово перед максимальной длиной и добавляет многоточие, чтобы получить аккуратно обрезанный текст без разрыва слов.