Как посчитать длину текста и не привлекать внимание санитаров
Привет! Меня зовут Ивасюта Алексей, я фронтенд-разработчик в Авито в кластере Seller Experience. В этой статье я расскажу, как правильно рассчитать длину текста в Java Script. Эта статья будет одинаково полезна как начинающим разработчикам, так и весьма опытным. Благодаря ей вы поймете устройство Unicode и особенности его работы в JS.
Немного предыстории
На нашей платформе можно размещать объявления и давать им описание в виде текста. Обычно для таких полей есть ограничение на максимальное количество вводимых символов. Если пользователь напечатал больше знаков, чем положено, сработает валидация и объявление нельзя будет разместить.
В целом, достаточно тривиальная практика. Жили мы, не тужили, разрешали вводить в поля только кириллицу, латиницу и цифры — и все работало прекрасно. Однажды мы подумали: «Почему бы не разрешить пользователям добавлять в описание эмодзи?». Сделали правочки, разрешили ввод новых символов и продолжили жить дальше. Но тут началось интересное: некоторые пользователи стали жаловаться, что они вводят символов меньше лимита, но валидацию описание все равно не проходит. Путем нехитрых проверок удалось найти интересные вещи. Давайте посмотрим на код:
'Фотоаппарат'.length
// 11
'Фотоаппарат?'.length
// 13
Длина слова «Фотоаппарат» равна 11. Если в конец добавить эмодзи, то длина становится равной 13. Может показаться, что это какой-то баг языка, но нет — всё правильно. И вот тут нам с вами придется нырнуть на самое дно Unicode.
Щепотка теории по Unicode
Unicode — это достаточно старый формат кодирования символов, который максимально широко распространился на данный момент. Он решает сложную задачу перекодирования текста, которая раньше стояла перед всеми разработчиками.
Особенность этого стандарта заключается в том, что за каждым символом, который добавлен в него объединением Unicode Consortium, навсегда закрепляется уникальный идентификатор. При помощи него любая программа на любой машине с любой локализацией может определить, какой символ представлен в тексте. Эти идентификаторы называются кодовыми точками. Значению кодовой точки соответствует всегда один и тот же символ.
В Unicode кодовую точку принято записывать в шестнадцатеричном виде и использовать не менее 4 цифр с ведущими нулями при необходимости. Например, число 0 в шестнадцатеричной системе счисления также имеет значение 0 и будет записано в виде U+0000 (U+ — префикс, обозначающий Unicode-символ). Это пустой символ или же аналог null/nil в Unicode. Обычно этот символ используется для обозначения конца null-терминированных строк в языке С (Си-строки).
null-терминированные строки
В языке С обработка строки произвольной длины происходит до того момента, пока не встретится нулевой символ.
Четыре разряда в шестнадцатеричной системе позволяют закодировать 164 комбинаций (или 216) — это 65 536 значений, каждое из которых является кодовой точкой. Значения находятся в диапазоне U+0000 — U+FFFF в шестнадцатеричной системе счисления. Этот диапазон значений называется «Базовой многоязычной плоскостью» или BMP. Она включает в себя символы из алфавитов самых распространенных языков мира, математические операторы, геометрические фигуры, специальные символы и многое другое. Важно заметить, что некоторые точки являются зарезервированными, некоторые из них не имеют графического представления, как например U+200D, а некоторые вообще не используются.
После того, как мы изучили теорию, давайте посмотрим на несколько примеров и пощупаем Unicode на практике.
Для получения значения кодовой точки в JS есть метод String.prototype.codePointAt()
. Если мы вызовем метод на строке с заглавной кириллической буквой «А», то получим значение кодовой точки в десятичной системе счисления.
'А'.codePointAt(0) // 1040
Теперь переведем полученное значение в шестнадцатеричную систему счисления.
'А'.codePointAt(0).toString(16) // '410'
В JS шестнадцатиразрядные числа можно записывать в формате 0x0000. Таким образом наше число можно записать в виде 0x410.
0x410 // 1040
typeof 0x410 === 'number' // true
Для получения Unicode-символа по значению кодовой точки JS предоставляет статический метод String.fromCodePoint()
. Давайте получим нашу букву обратно.
String.fromCodePoint(0x410) // 'А'
Для записи символов Unicode можно использовать формат \u0000
. Если число меньше четырех знаков, то недостающие заполняются нулями слева. Таким образом нашу букву также можно представить в виде строки.
'\u0410' // 'А'
Теперь мы можем даже составить целое слово. Вставьте эту строку в консоль и посмотрите на результат.
'\u041f\u0440\u0438\u0432\u0435\u0442'
Нормализация и комбинируемые символы
Перейдем к неочевидному поведению строк в Java Script. Возьмем для примера такой экзотический символ, как «Слог Хангыль ggag» из слогового письма Хангыля и посчитаем его длину.
'깍'.length // 3
Ого! Символ один, а длина строки почему-то равна трем. На самом деле, этот символ состоит из трех кодовых точек U+1101, U+1161 и U+11A8. Вместе эти знаки в Хангыльском письме образуют иероглиф 깍
, который сам является отдельным символом и имеет кодовую точку U+AE4D.
Здесь мы приходим к двум важным выводам:
Длина строки в JS считается по количеству кодовых точек, из которых она состоит.
При подсчете длины строки надо учитывать количество графем, а не кодовых точек.
Так, у нас появилось новое понятие. Графема — это минимальная единица письменности. В нашем случае, три кодовые точки образуют одну графему, которая отображается в виде иероглифа 깍
.
В Unicode для таких случаев описаны алгоритмы нормализации, когда комбинируемые символы могут быть заменены на один составной. JS предоставляет метод String.prototype.normalize
, который позволяет проводить нормализацию строк по описанным в Unicode алгоритмам. После выполнения нормализации три кодовые точки будут заменены на одну — U+AE4D.
'깍'.normalize()
.codePointAt(0)
.toString(16) // 'ae4d'
'깍'.normalize('NFC').length // 1
Можно комбинировать различные символы в Unicode, но для результата комбинации может не быть предусмотрено отдельной кодовой точки.
'ко̅д'.normalize().length // 4
Длина строки в этом случае равна 4, а видим мы всего три графемы. Символ о̅
состоит из обычной кириллической буквы «о» и Unicode-символа комбинируемого надчеркивания U+0305.
'ко\u0305д' // ко̅д
В таких случаях при учете длины строки нужно исключать из расчета комбинируемые символы. Они не создают отдельных графем, а просто видоизменяют отображение рядом стоящих. Для этого можем воспользоваться экранированием свойств Unicode в регулярках JS.
const regexSymbolWithCombiningMarks = /(\P{Mark})(\p{Mark}+)/ug;
const countTextLength = (text) => {
const normalizedText = text
.normalize('NFC')
.replace(regexSymbolWithCombiningMarks, function(_, symbol) {
return symbol;
});
return normalizedText.length;
};
countTextLength('ко̅д') // 3
countTextLength('깍') // 1
Селекторы начертания
В Unicode есть диапазон кодовых точек U+FE00 — U+FE0F для селекторов начертания. Это невидимые символы, которые изменяют начертание предшествующих им. Самый интересный — U+FE0F. Этот селектор указывает, что предыдущий символ должен отображаться в виде эмодзи, если предыдущий символ по умолчанию имеет текстовое представление. Например, кодовая точка U+2764 по умолчанию будет отображаться как закрашенное жирное сердечко ❤
. Если мы добавим к нему селектор начертания U+FE0F, то отображаться он уже будет в виде эмодзи сердечка ❤️.
'❤\ufe0f' // ❤️
Отображение в консоли браузера
В консоли браузера и здесь в примере кода вы все равно увидите отображение в виде закрашенного черного сердечка, но если скопировать полученный символ в мессенджер, то там будет эмодзи.
То же самое можно проделать со снеговиком.
'☃\ufe0f' // ☃️
Селекторы начертания не создают отдельных графем, а лишь изменяют отображение предшествующих. Они не должны учитываться при подсчете длины текста. В вычислениях их можно убрать, например, регуляркой.
'❤\ufe0E'.replace(/[\u{FE00}-\u{FE0F}]/ug, '').length // 1
Суррогаты
Однажды IT-сообществу захотелось добавить в Unicode больше символов, а диапазон в 65К кодовых точек стал тесноват. Ребята из Unicode Consortium призадумались и решили заложить в стандарт больше возможностей для дальнейшего расширения.
Так как надо было сохранить совместимость с уже существующими значениями и сильно увеличить количество допустимых, диапазон кодовых точек расширили до U+10FFFF. В Unicode появилось еще 16 плоскостей по 65 536 символов в каждой. Таким образом, количество возможных значений увеличилось до 1 114 112. Сейчас в Unicode есть эмодзи, кости для маджонга, алхимические символы, символы «Канона великого сокровенного» и куча всего другого.
Теперь нам надо немного вспомнить азы программирования. Для кодирования каждого символа необходимо n бит. Число 1 в двоичной системе счисления будет иметь вид 1, а для его кодирования нужен 1 бит. Число 65535 будет иметь вид 1111111111111111
. У него 16 разрядов, соответственно, для хранения нужно 16 бит, то есть 2 байта.
Существует кодировка UTF-8, которая использует 8 бит на кодирование каждого символа. При помощи нее можно закодировать 256 различных знаков (28). Строки в этой кодировке занимают очень мало места: текст в 10 000 знаков будет занимать 10 000 байт или 9,77 КБ. Это позволяет оптимизировать использование памяти.
Есть кодировка UTF-32, которая использует 32 бита на кодирование каждого символа. Здесь больше 4 миллиардов комбинаций. Как можно догадаться, любой символ из любой плоскости Unicode может быть с легкостью закодирован, но строки в ней занимают много памяти. Так текст в 10 000 знаков уже будет занимать больше 32 КБ.
Самое интересное происходит в системах, которые используют шестнадцатибитную кодировку представления. JavaScript использует именно кодировку UTF-16, которая позволяет выделять на хранение каждого символа 2 байта. Для хранения кодовой точки из BMP этого достаточно, но для точек из других плоскостей нужно больше бит.
Число 10000 в шестнадцатеричной системе преобразуется в 10000000000000000
в двоичной. У числа 17 разрядов, а значит для кодирование уже нужно 17 бит. Уместить 17 бит в 2 байта никак нельзя.
Чтобы кодировать символы из астральных плоскостей в 16-битных кодировках были придуманы суррогатные пары. Суррогаты — это зарезервированный диапазон значений в базовой плоскости Unicode, который делится на две части:
U+D800 – U+DBFF — верхние суррогаты;
U+DC00 – U+DFFF — нижние суррогаты.
В каждый диапазон входит 210 символов. То есть всего возможно 220 комбинаций — это 1 048 576 значений. Добавим сюда 65 536 значений из BMP и получим 1 114 112 значений. Таким образом мы можем закодировать кодовые точки из всех плоскостей Unicode.
Точки из BMP кодируются «как есть». Если же надо закодировать кодовую точку из астральной плоскости, то для нее вычисляется пара суррогатов по формуле:
const highSurrogate = Math.floor((codepoint - 0x10000) / 0x400) + 0xD800;
const lowSurrogate = (codepoint - 0x10000) % 0x400 + 0xDC00;
Для обратного преобразования из суррогатной пары в кодовую точку используется следующая формула:
const codePoint = (highSurrogate - 0xD800) * 0x400 + lowSurrogate - 0xDC00 + 0x10000;
Таким образом, ухмыляющийся смайлик U+1F600 будет преобразован в суррогатную пару U+D83D + U+DE00.
'?' === '\u{d83d}\u{de00}'
// true
Именно поэтому длина этого эмодзи равна 2.
Формат записи кодовых точек
До этого момента вы видели запись кодовых точек в строках только в виде \u0000
. Такой формат записи является устаревшим и будет работать только для кодовых точек из BMP. Вместо него используйте запись вида \u{0000}
. Такой формат работает для кодовых точек из любой плоскости.
'?'.length === 2
// true
Чтобы учитывать эмодзи при подсчёте, в JavaScript есть итератор строк, который учитывает суррогатные пары и итерирует их, как одну графему. Это значит, что длину строки с учетом эмодзи можно подсчитать итерируясь по строке и запоминая количество итераций.
const countGraphemes = (text) => {
let count = 0;
for(const _ of text) {
count++;
}
return count;
}
countGraphemes('текст ?') // 7
То же самое можно сделать разбив строку на массив.
Array.from('текст ?').length // 7
// или
[...'текст ?'].length // 7
Модификаторы цвета
В Unicode есть пять модификаторов цвета кожи по шкале Фитцпатрика в диапазоне U+1F3FB —U+1F3FF.
При помощи этих модификаторов можно персонифицировать «базовые» эмодзи.
'?\u{1f3fb}'
// '??'
'?\u{1f3fc}'
// '??'
'?\u{1f3fd}'
// '??'
'?\u{1f3fe}'
// '??'
'?\u{1f3ff}'
// '??'
Как вы уже могли догадаться, модификаторы цвета при подсчете длины текста учитывать не надо.
Объединитель нулевой ширины
Давайте подсчитаем длину эмодзи семьи из трех человек.
'???'.length
// 8
Весьма неожиданный результат. Давайте теперь разобьем строку на массив символов.
[...'???']
// ['?', '', '?', '', '?']
Теперь мы видим настоящую магию: эмодзи семьи из трех человек на самом деле состоит из трех базовых эмодзи, которые соединены объединителем нулевой ширины или ZWJ. Этот символ не имеет графического отображения и представлен кодовой точкой U+200D. А при помощи такой хитрой конструкции мы можем собрать новый эмодзи из базовых.
['?', '\u{200d}', '?', '\u{200d}', '?'].reduce((prev, curr) => prev + curr)
// '???'
Мы получили эмодзи отца-одиночки с двумя детьми.
В этом случае уже сложнее подсчитать правильную длину текста с учетом составных эмодзи, поэтому придется разбивать строку на массив и итерироваться по нему. При этом комбинации, которые состоят из нескольких символов, соединенных ZWJ, необходимо считать за одну графему.
Собираем все вместе
const regex = /[\u{FE00}-\u{FE0F}]|[\u{1F3FB}-\u{1F3FF}]/ug;
// \u{FE00}-\u{FE0F} -- селекторы начертания
// \u{1F3FB}-\u{1F3FF} - модификаторы цвета
const regexSymbolWithCombiningMarks = /(\P{Mark})(\p{Mark}+)/ug; // Поиск комбинируемых символов
const joiner = '200d'; // Объединитель нулевой ширины («Zero Width Joiner», ZWJ)
// Проверяет, является ли символ ZWJ
const isJoiner = (char) => char.charCodeAt(0).toString(16) === joiner;
const countGraphemes = (text) => {
const normalizedText = text
// нормализуем строку
.normalize('NFC')
// удаляем селекторы начертания и модификаторы цвета
.replace(regex, '')
// удаляем комбинируемые символы
.replace(regexSymbolWithCombiningMarks, function(_, symbol) {
return symbol;
});
// Разделяем строку на токены. Кодовые точки из суррогатных пар будут корректно
// выделены в одну графему.
const tokens = Array.from(normalizedText);
let length = 0;
let isComplexChar = false; // Обработка комплексных символов, склеенных через ZWJ
// Итеррируемся по массиву токенов и комбинации, склеенные через ZWJ
// считаем за одиу графему
for (let i = 0; i < tokens.length; i++) {
const char = tokens[i];
const nextChar = tokens[i + 1];
if (!nextChar) {
length += 1;
continue;
}
if (isJoiner(nextChar)) {
isComplexChar = true;
continue;
}
if (!isJoiner(char) && isComplexChar) {
isComplexChar = false;
}
if (!isComplexChar) {
length += 1;
}
}
return length;
}
countGraphemes('test?') // 5
countGraphemes('??????') // 2
countGraphemes('깍') // 1
// и так далее
Конечно, это не самая оптимальная реализация и приведенный выше код необходим лишь для примера и наглядности. К тому же, мы все еще не учитываем множество особенностей Unicode. Этот алгоритм будет работать для подавляющего большинства обычных приложений, но по настоящему честный подсчет для всех возможных языков и начертаний он сделать не сможет.
Intl.Segmenter
Как вы видите, алгоритм подсчета количества графем получается не самым простым. Здесь требуется учитывать множество деталей и быть глубоко погруженным в тему. И вот наконец-то настает очередь Intl.Segmenter
— специальный класс, который включает сегментацию текста с учетом языковых стандартов. Это позволяет получать значимые элементы из строки, например, графемы, слова или предложения.
const segmenter = new Intl.Segmenter();
const text = '????깍ко̅д??';
const iterator = segmenter.segment(text)[Symbol.iterator]();
let count = 0;
for (const symbol of iterator) {
count++;
}
console.log(count) // 6
Вот так легко и непринуждённо мы смогли в несколько строчек проделать ту же работу. По умолчанию сегментер бьет текст на графемы, но ему можно дополнительно задать локаль и степень сегментации, например, по словам.
const segmenter = new Intl.Segmenter('fr', { granularity: 'word' });
У меня для вас только одна плохая новость: Intl.Segmenter
не поддерживается в Firefox, поэтому кроссбраузерное решение сделать на нем не получится. Пока что вам остается искать рабочий полифил для него и подключать в нужном месте при выполнении кода. Также нужно убедиться в работоспособности сегментации через полифил.
Что в результате
Честный подсчет длины введенного пользователем текста — дело непростое из-за особенностей Unicode и 16-битной кодировки. Если вы хотите сделать по-настоящему интернациональное приложение, например, мессенджер, вам придется учитывать все эти сценарии. Эту проблему призван решить Intl.Segmenter
, поэтому с нетерпением ждем его поддержки в Firefox.
Полезные ссылки
Алгоритмы нормализации Unicode