17 сентября

Intl.Segmenter: сегментация Юникода в JavaScript

JavaScriptGoogle ChromeNode.JSБраузерыЛокализация продуктов
Перевод
Автор оригинала: Daniel Ehrenberg

Предисловие к переводу


Это перевод объяснительной части предложения (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

API


Полифил для исторической фиксации данного предложения.


new Intl.Segmenter(locale, options)


Создаёт новый сегментатор согласно региональным настройкам.


Если аргумент options задан, он воспринимается как объект со свойством granularity, задающим степень сегментации ("grapheme" (по графемам), "word" (по словам) или "sentence" (по предложениям); по умолчанию — "grapheme").


Intl.Segmenter.prototype.segment(string)


Создаёт для обрабатываемой строки новый экземпляр %Segments% с интерфейсом Iterable согласно региональным настройкам и степени сегментации.


Данные сегментации


Сегменты описываются при помощи обычных объектов со следующими свойствами:


  • segment — сегмент строки.
  • index — индекс единицы кодирования (code unit index) в строке, с которого начинается сегмент.
  • input — сегментируемая строка.
  • isWordLiketrue, если степень сегментации задана как "word" (по словам) и сегмент похож на слово (состоит из букв/чисел/идеограмм/и т.д.); false, если степень сегментации задана как "word" и сегмент не похож на слово (состоит из пробелов/пунктуации/и т.д.); и undefined, если степень сегментации задана не как "word".

Методы %Segments%.prototype:


%Segments%.prototype.containing(index)


Возвращает объект данных сегментации, описывающий сегмент, содержащий единицу кодирования (code unit) по заданному индексу, или undefined, если индекс выходит за границы строки.


%Segments%.prototype[Symbol.iterator]


Создаёт новый экземпляр %SegmentIterator%, который будет осуществлять "ленивую" (lazy, по мере необходимости) итерацию по строке, находя сегменты в соответствии с региональными настройками и степенью сегментации и сохраняя информацию о текущей позиции внутри строки.


Методы %SegmentIterator%.prototype:


%SegmentIterator%.prototype.next()


Метод next() осуществляет интерфейс Iterator, находя следующий сегмент и возвращая соответствующий объект типа IteratorResult, чьё свойство value содержит объект из данных сегментации, описанный выше.


FAQ


Почему мы должны задавать региональные настройки и набор опций для разбора по графемам? Разве не существует всего один способ такого разбора?


Ситуация немного сложнее — например, для индийской письменности. Работа над лучшей настройкой графемной сегментации этих систем письма ещё продолжается. См. данное обсуждение проблемы и в особенности эту страницу из вики CLDR. По всей видимости, CLDR/ICU пока ещё не поддерживают такую настройку, но она планируется.


Не должны ли мы помещать новые API во встроенные модули?


Если бы встроенные модули получили распространение прежде, чем данное предложение достигло 3-й стадии, это было бы хорошим решением. Однако пока в TC39 решили не блокировать одно нововведение в ожидании другого. Разработчики встроенных модулей ещё не решили все существенные проблемы; например, окончательно не определено, должны ли с модулями взаимодействовать полифилы и как это будет осуществляться.


Почему не включена сегментация по переводам строк?


Такая сегментация была включена в раннюю версию данного API, но её исключили, потому что простое API разделения по строчкам было бы недостаточным: разбиение на строчки обычно используется при форматировании текста, а оно требует более широкого набора API (например, определения ширины отрисованного фрагмента текста). По этой причине мы предложили сделать разработку API для разделения по строчкам частью проекта CSS Houdini.


Почему не включена сегментация по переносам слов?


По разным причинам расстановка переносов требует иного формата API:


  • Вставка переноса может изменить правописание обрабатываемого текста.
  • Расстановка переносов может подчиняться разным приоритетам.
  • Переносы сложным образом взаимодействуют с форматированием строчек и отрисовкой шрифтов, поэтому подобная сегментация может больше подходить этому уровню (т.е. её лучше включить в Web API (Web Platform), чем в ECMAScript).
  • Расстановка переносов не так хорошо разработана, как другие области интернационализации. CLDR и ICU её ещё не поддерживают. Некоторые браузеры только сейчас добавляют поддержку для неё в CSS, и она далека от совершенства. Стоит дать ей время отшлифоваться. В отличие от переносов, сегментации по словам, графемам, предложениям и строчкам давно уже занимают своё место в спецификации Юникода; с ними можно работать здесь и сейчас.

Почему произвольный доступ не сохраняет состояние?


Вполне возможно предоставить методы %SegmentIterator%.prototype, меняющие внутреннее состояние (например, seek([inclusiveStartIndex = thisIterator.index + 1]) и seekBefore([exclusiveLastIndex = thisIterator.index]), и такие методы даже были частью ранних замыслов. Но от них отказались в пользу соответствия другим итераторам ECMA-262 (чьё продвижение всегда направлено вперёд и лишено пропусков). Если практика покажет, что отсутствие таких методов вредит эргономике или быстродействию, они могут быть добавлены в последующем предложении.


Почему API стало частью Intl, а не методами String?


Определение перечисленных границ зависит от региональных настроек, а некоторые виды сегментации подразумевают сложный набор параметров. Метод 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.


  1. Joel Spolsky. The Absolute Minimum Every Software Developer Absolutely, Positively Must Know About Unicode and Character Sets (No Excuses!)
  2. Dmitri Pavlutin. What every JavaScript developer should know about Unicode
  3. Dr. Axel Rauschmayer. JavaScript for impatient programmers: 17. Unicode – a brief introduction
  4. Dr. Axel Rauschmayer. JavaScript for impatient programmers: 18.6. Atoms of text: Unicode characters, JavaScript characters, grapheme clusters
  5. Jonathan New. "\u{1F4A9}".length === 2
  6. Nicolás Bevacqua. ES6 Strings (and Unicode, ) in Depth
  7. Mathias Bynens. JavaScript has a Unicode problem
  8. Mathias Bynens. Unicode-aware regular expressions in ECMAScript 6
  9. Mathias Bynens. Unicode property escapes in JavaScript regular expressions
  10. Mathias Bynens. Unicode sequence property escapes
  11. Awesome Unicode: a curated list of delightful Unicode tidbits, packages and resources
Теги:javascriptnode.jsbrowsersgoogle chromev8unicodeюникодlocalizationлокализацияinternationalizationинтернационализация
Хабы: JavaScript Google Chrome Node.JS Браузеры Локализация продуктов
+5
987 11
Комментарии 2
Лучшие публикации за сутки