Intl.Segmenter: сегментация Юникода в JavaScript
- пятница, 18 сентября 2020 г. в 00:33:46
Это перевод объяснительной части предложения (proposal) Intl.Segmenter
, которое скорее всего будет добавлено в ближайшую спецификацию ECMAScript.
Предложение уже реализовано в V8 и без флага может быть использовано в версии 8.7 (точнее в 8.7.38
и выше), поэтому его можно протестировать в Google Chrome Canary (начиная с версии 87.0.4252.0
) или в Node.js V8 Canary (начиная с версии v15.0.0-v8-canary202009025a2ca762b8
; для Windows бинарники доступны с версии v15.0.0-v8-canary202009173b56586162
).
Если будете тестировать в более ранних версиях с флагом --harmony-intl-segmenter
, будьте осторожны, так как спецификация менялась и реализация под флагом может быть устаревшей. Проверяйте по выводу в примерах кода.
После перевода приведены ссылки на материалы об основаниях проблем, которые решает данное предложение.
Intl.Segmenter
: сегментация Юникода в JavaScriptПредложение находится на стадии 3, при поддержке Ричарда Гибсона (Richard Gibson).
Кодовая позиция (code point) в Юникоде не является «буквой» или единицей отображения текста на экране. Эту роль выполняет графема, которая может состоять из нескольких кодовых позиций (например, включать знаки ударения или соединяющие символы корейской письменности). Юникод определяет алгоритм для вычленения графем, помогающий находить границы между ними. Это может быть полезным при создании современных редакторов, средств ввода или других форм обработки текста.
Юникод также определяет алгоритмы для нахождения границ между словами и предложениями, которые CLDR (Common Locale Data Repository, Общий репозиторий языковых данных) распределяет между региональными настройками (локалями, locales). Эти границы могут помочь, например, при создании текстового редактора с поддержкой команд перехода между словами или предложениями, а также их подсветки.
Сегментация на графемы, слова и предложения определена в UAX 29. Браузеры для полноценной работы нуждаются в реализации этого сегментирования, и поддержка его на уровне самого языка JavaScript снизит нагрузку на память и сеть по сравнению с авторскими реализациями разработчиков.
Chrome на протяжении нескольких лет предоставлял собственную нестандартную сегментацию через API под названием Intl.v8BreakIterator
. Однако по некоторым причинам это API не показалось подходящим для стандартизации. Данная объяснительная часть описывает новое API, которое мы попытались согласовать с современным дизайном API в JavaScript — характерным для эпохи, наступившей после ES2015.
Объекты, возвращаемые segment()
, методом экземпляра Intl.Segmenter
, находят границы и предоставляют сегменты между ними при помощи интерфейса Iterable.
// Создаём сегментатор слов с региональными настройками.
let segmenter = new Intl.Segmenter("fr", {granularity: "word"});
// Используем сегментатор для получения итератора по строке.
let input = "Moi? N'est-ce pas.";
let segments = segmenter.segment(input);
// Используем итератор для сегментации!
for (let {segment, index, isWordLike} of segments) {
console.log("segment at code units [%d, %d): «%s»%s",
index, index + segment.length,
segment,
isWordLike ? " (word-like)" : ""
);
}
// Вывод console.log:
// segment at code units [0, 3): «Moi» (word-like)
// segment at code units [3, 4): «?»
// segment at code units [4, 6): « »
// segment at code units [6, 11): «N'est» (word-like)
// segment at code units [11, 12): «-»
// segment at code units [12, 14): «ce» (word-like)
// segment at code units [14, 15): « »
// segment at code units [15, 18): «pas» (word-like)
// segment at code units [18, 19): «.»
Для большей гибкости и особых случаев, API также поддерживает прямой произвольный доступ.
// ┃0 1 2 3 4 5┃6┃7┃8┃9
// ┃A l l o n s┃-┃y┃!┃
let input = "Allons-y!";
let segmenter = new Intl.Segmenter("fr", {granularity: "word"});
let segments = segmenter.segment(input);
let current = undefined;
current = segments.containing(0)
// → { index: 0, segment: "Allons", isWordLike: true }
current = segments.containing(5)
// → { index: 0, segment: "Allons", isWordLike: true }
current = segments.containing(6)
// → { index: 6, segment: "-", isWordLike: false }
current = segments.containing(current.index + current.segment.length)
// → { index: 7, segment: "y", isWordLike: true }
current = segments.containing(current.index + current.segment.length)
// → { index: 8, segment: "!", isWordLike: false }
current = segments.containing(current.index + current.segment.length)
// → undefined
Полифил для исторической фиксации данного предложения.
new Intl.Segmenter(locale, options)
Создаёт новый сегментатор согласно региональным настройкам.
Если аргумент options
задан, он воспринимается как объект со свойством granularity
, задающим степень сегментации ("grapheme"
(по графемам), "word"
(по словам) или "sentence"
(по предложениям); по умолчанию — "grapheme"
).
Intl.Segmenter.prototype.segment(string)
Создаёт для обрабатываемой строки новый экземпляр %Segments%
с интерфейсом Iterable согласно региональным настройкам и степени сегментации.
Сегменты описываются при помощи обычных объектов со следующими свойствами:
segment
— сегмент строки.index
— индекс единицы кодирования (code unit index) в строке, с которого начинается сегмент.input
— сегментируемая строка.isWordLike
— true
, если степень сегментации задана как "word"
(по словам) и сегмент похож на слово (состоит из букв/чисел/идеограмм/и т.д.); false
, если степень сегментации задана как "word"
и сегмент не похож на слово (состоит из пробелов/пунктуации/и т.д.); и undefined
, если степень сегментации задана не как "word"
.%Segments%.prototype.containing(index)
Возвращает объект данных сегментации, описывающий сегмент, содержащий единицу кодирования (code unit) по заданному индексу, или undefined
, если индекс выходит за границы строки.
%Segments%.prototype[Symbol.iterator]
Создаёт новый экземпляр %SegmentIterator%
, который будет осуществлять "ленивую" (lazy, по мере необходимости) итерацию по строке, находя сегменты в соответствии с региональными настройками и степенью сегментации и сохраняя информацию о текущей позиции внутри строки.
%SegmentIterator%.prototype.next()
Метод next()
осуществляет интерфейс Iterator, находя следующий сегмент и возвращая соответствующий объект типа IteratorResult, чьё свойство value
содержит объект из данных сегментации, описанный выше.
Ситуация немного сложнее — например, для индийской письменности. Работа над лучшей настройкой графемной сегментации этих систем письма ещё продолжается. См. данное обсуждение проблемы и в особенности эту страницу из вики CLDR. По всей видимости, CLDR/ICU пока ещё не поддерживают такую настройку, но она планируется.
Если бы встроенные модули получили распространение прежде, чем данное предложение достигло 3-й стадии, это было бы хорошим решением. Однако пока в TC39 решили не блокировать одно нововведение в ожидании другого. Разработчики встроенных модулей ещё не решили все существенные проблемы; например, окончательно не определено, должны ли с модулями взаимодействовать полифилы и как это будет осуществляться.
Такая сегментация была включена в раннюю версию данного API, но её исключили, потому что простое API разделения по строчкам было бы недостаточным: разбиение на строчки обычно используется при форматировании текста, а оно требует более широкого набора API (например, определения ширины отрисованного фрагмента текста). По этой причине мы предложили сделать разработку API для разделения по строчкам частью проекта CSS Houdini.
По разным причинам расстановка переносов требует иного формата API:
Вполне возможно предоставить методы %SegmentIterator%.prototype
, меняющие внутреннее состояние (например, seek([inclusiveStartIndex = thisIterator.index + 1])
и seekBefore([exclusiveLastIndex = thisIterator.index])
, и такие методы даже были частью ранних замыслов. Но от них отказались в пользу соответствия другим итераторам ECMA-262 (чьё продвижение всегда направлено вперёд и лишено пропусков). Если практика покажет, что отсутствие таких методов вредит эргономике или быстродействию, они могут быть добавлены в последующем предложении.
Определение перечисленных границ зависит от региональных настроек, а некоторые виды сегментации подразумевают сложный набор параметров. Метод segment()
возвращает SegmentIterator
. Для многих подобных нетривиальных задач, аналогичные API помещены в объект Intl, принадлежащий ECMA-402. Это позволяет процессам создания экземпляров делиться ресурсами, что повышает производительность. Метод класса String, который облегчал бы разработку, можно добавить в последующем предложении.
Индекс n относится к индексу единицы кодирования (code unit), которая может быть началом сегмента. Например, при итерации по английским словам относительно строки "Hello, world\u{1F499}" (Хабр не отобразает эмодзи, поэтому вместо сердечка вставлена эскейп-последоватеьность — примечание переводчика), сегменты будут начинаться с индексов 0
, 5
, 6
, 7
и 12
. То есть строка сегментируется так: ┃Hello┃,┃ ┃world┃\u{1F499}┃
, причём последний сегмент состоит из суррогатной пары двух единиц кодирования (code units), кодирующих позицию Юникода (code point). Данная индексация границ не зависит от направления итерации, прямого или обратного.
Не будет найдено ни одного сегмента, итератор завершит работу при первом же вызове метода next()
.
О, кто-то работает в QA ;)
Аргументы приводятся к целому числу типа Number
: null
становится 0
, булевы значения — 0
или 1
, строки разбираются как строчные литералы чисел, объекты приводятся к примитивам, а значения типов Symbol
и BigInt
, равно как undefined
и NaN
приводят к неудачному поиску*. Дробные части отсекаются, но бесконечные числа принимаются как есть (хотя они всегда выходят за границы строки, поэтому с таким аргументом сегмент не будет найден).
* Примечание переводчика. В оригинале в этом месте просто "fail". Согласно спецификации и тестам в последней версии Chrome Canary, значения типа Symbol
и BigInt
вызывают TypeError, но поиск с параметрами undefined
и NaN
находит тот же сегмент, что и поиск с индексом 0
.
Следующие статьи служат хорошим введением к работе с Юникодом в JavaScript.