Привет! Меня зовут Ивасюта Алексей, я фронтенд разработчик в Авито в кластере 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. 

Здесь мы приходим к двум важным выводам:

  1. Длина строки в JS считается по количеству кодовых точек, из которых она состоит.

  2. При подсчете длины строки надо учитывать количество графем, а не кодовых точек.

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

В 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 

Подробнее о графемах 

Алгоритмы нормализации Unicode 

Документация Intl.Segmenter на MDN 

Подробнее про Intl.Segmenter

Экранирование свойств Unicode 

Комментарии (39)


  1. amarao
    07.07.2022 17:09
    +2

    йод - это три глифа или четыре? Что-то у вас проблема диакритики слегка упрощена. Плюс, вы не привели пример из корейского, где добавление символа к строке сокращает число видимых глифов.


    1. avivasyuta Автор
      07.07.2022 17:40
      +1

      В слове «йод» три графемы и три кодовые точки: U+0439, U+043E, U+0434. При этом возможна запись буквы «й» в виде пары буквы «и» и комбинируемого бреве U+0306. Эти примеры рассмотрены в статье.

      Какие еще проблемы диактритики вы имеете в виду?

      В конце статьи четко написано, что рассмотрены далеко не все примеры и особенности, потому что все их охватить в одном материале сложно.


      1. amarao
        08.07.2022 11:49

        Я неправильно прочитал. Вы очень вольно слова "длина" использовали, и я подумал, что на слово с диакритикой вы считаете длину 4.


  1. GothicJS
    07.07.2022 17:53
    -5

    Отличная статья! Я бы поставил плюс, да какие то школьники карму заминусовали)


    1. avivasyuta Автор
      07.07.2022 19:43
      +1

      Спасибо за оценку)


  1. 4reddy
    08.07.2022 01:06
    +2

    Давно здесь не было настолько интересных, полезных и при этом достаточно простых статей. Спасибо.


    1. avivasyuta Автор
      08.07.2022 09:31
      +1

      Спасибо! Очень приятно получить такой отзыв.


  1. leotrubach
    08.07.2022 09:28

    Спасибо большое за статью, все по полочкам.


  1. morsick
    08.07.2022 09:28
    +3

    В Firefox сейчас если в консоль прописать '깍'.length то возвращает 1, а не 3. Получается строка автоматически нормализуется или дело в другом?


    1. avivasyuta Автор
      08.07.2022 09:46
      +1

      Я по ошибке вставил в пример уже нормализованный глиф. Спасибо, что заметили. Уже исправил пример.


  1. igrishaev
    08.07.2022 10:42

    А зачем эмодзи в объявлениях? Какой смысл в иконке фотоаппарата рядом со словом "фотоаппарат"?


    1. amarao
      08.07.2022 11:52

      Выделяет на фоне объявлений без картинки. Я вот даже в самых важных PR или сообщениях иногда эмоди ставлю.

      ‼️ Это делает надпись очень заметной ????


      1. igrishaev
        08.07.2022 12:02
        +1

        Как по мне, просто мусора в текст вкинули, тем и заметней.


        1. amarao
          08.07.2022 12:22
          +1

          Ну, а какая цель объявления?


    1. avivasyuta Автор
      08.07.2022 13:20

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


      Пример с фотоаппаратом синтетический, чтобы показать особенности работы JS.


  1. Iv38
    08.07.2022 11:49
    +1

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


    1. avivasyuta Автор
      08.07.2022 13:22

      Да, проблема правда очень сложная. Снимаю шляпу перед людьми, которые делали Intl.Segmenter и подобные вещи.


  1. Stalker_RED
    08.07.2022 12:42
    -1

    Если копнуть глубже, то ограничение по количеству символов скорее всего происходит от требования дизйнеров UI/UX, которое звучит примерно как "для читаемости и удобства объявление должно умещаться в поле такого-то размера".

    Помещаем текст в блок заданного размера, проверяем сработал ли overflow, и всё, кодовые точки считать не нужно.

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


    1. avivasyuta Автор
      08.07.2022 13:28
      +1

      Ваш метод подсчета абсолютно не жизнеспособен, к сожалению. Ограничивая таким образом ввод вы никак не учитываете то, что все символы имеют разную ширину и в один и тот же блок может уместиться их разное количество. Таким образом вы ставите пользователей в неравное положение, когда один может написать условно 1000 знаков, а у другого вместится всего 900.

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


      1. Kriminalist
        08.07.2022 14:29

        вы ставите пользователей в неравное положение, когда один может написать условно 1000 знаков, а у другого вместится всего 900

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

        1111 РЫБА ....

        А про длину строк и потребности железа - тут можно согласиться, но, опять же, как рассчитать размер блока - если под самые узкие символы, то широких в строке будет меньше, длина строки будет меньше, прогнозирование с запасом получится.


        1. avivasyuta Автор
          08.07.2022 14:42
          +1

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

          Разница существенно увеличивается с увеличением ограничения на длину. Попробуйте объяснить пользователям, почему они видят объявления с 10 000 знаков в описании, а сами могу написать максимум 9 000, например. У вас это не получится сделать. Вы получите кучу негатива и багрепортов от пользователей.

          Решение с оверлеем — это классический пример костыля, когда у вас нет экспертизы/желания, чтобы сделать правильно.

          - Решение не выносит никакой критики для адаптивности. На мобилках оно просто не будет работать.
          - Размеры оверлея я всегда могу поправить руками и обойти валидацию в 2 клика
          - Такую же валидацию должен уметь делать сервер, потому что клиентскую валидацию всегда можно обойти. Сервер не сможет сделать это никак, кроме как имея константу на количество знаков.


          1. Stalker_RED
            08.07.2022 15:02

            Про несправедливость, в которой кому-то достаётся больше символов - это да, это не учитывал.

            Честно говоря, ни разу не размещал на авто объявления, только просматривал. Даже представить не могу пользователей, которые пишут 10к знаков в объявлении (многие рассказы заметно короче), да ещё и сравнивают их длину с другими, но мир полон чудес.

            И вы всерьез беспокоитесь о том, будет ли база весить 100 гигабайт или 120? Наверняка ведь тексты вынесены в отдельные таблицы с партиционированием, возможно шардированием. И рядом поисковые индексы на sphinx или каком-то подобном движке, которые занимают места побольше, чем сами тексты.

            Ну и если уж мы рассуждаем о костылях, то нельзя не упомянуть и велосипеды :) https://github.com/orling/grapheme-splitter


            1. avivasyuta Автор
              08.07.2022 15:12
              +1

              Даже представить не могу пользователей, которые пишут 10к знаков в объявлении (многие рассказы заметно короче)

              Существуют продавцы, особенно профессиональные, которые используют описание по максимуму.

              И вы всерьез беспокоитесь о том, будет ли база весить 100 гигабайт или 120?

              Надо уметь прогнозировать требования к железу при изменениях в таких больших проектах. В наших реалиях речь идет далеко не о сотнях ГБ.

              Ну и если уж мы рассуждаем о костылях, то нельзя не упомянуть и велосипеды :)

              Решение в статье не позиционируется, как альтернатива библиотеке. Я рассматриваю проблему и привожу примеры, как с ней можно бороться. В своем проекте вы можете использовать для решения этой проблемы вышеописанную библиотеку, если считаете это целесообразным.


          1. Kriminalist
            08.07.2022 15:31

            Попробуйте объяснить пользователям, почему они видят объявления с 10 000 знаков в описании,

            Так описание будет про размер окна, а не про 10 тыщ знаков :).

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

            Техническая реализация может быть разной, не обязательно оверлеи, просто проверку реализовать по принципу влазит ли текст в окно (вообще условное, 1000 х 1000 пикселей например) один раз, а отрисовывать на устройстве уже дальше.


            1. avivasyuta Автор
              08.07.2022 17:06
              +1

              Перечитайте мои объяснения еще раз, пожалуйста. Проверки делать по принципу "сколько влезет" нельзя. У каждого пользователя должно быть четкое понимание, сколько знаков он может использовать. При этом каждый пользователь должен иметь идентичные возможности. А сервер должен уметь также проверять ограничения.

              Ну и вы ничем не подкрепили утверждение, что с увеличением количества символов разница будет расти, напротив, она скорее будет уменьшаться, чисто статистически.

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


              1. Kriminalist
                08.07.2022 18:03

                Да я понимаю ваши обьяснения, и реализация подхода вызывает уважение.

                Но тезис "У каждого пользователя должно быть четкое понимание, сколько знаков он может использовать" не догма совершенно, а искуственно навязываемое пользователю ограничение. В реальной жизни только одна архаичная аналогия - телеграммы, с оплатой за знаки.

                Пользователь оперирует не знаками, а абстракциями более высокого порядка - словами, предложениями, устойчивыми оборотами. Книга на 100 страниц может содержать роман, а может сборник стихов, количество знаков на порядки может отличаться, но на полке они будут занимать совершенно одинаковое место. Ограничение размером носителя - совершенно привычное бытовое, это формат бумаги, это размер тарелки, это площадь участка..

                Ценность текста (и обявления в частности) не определяется количеством знаков, там более, если речь идет о порядках 10 тыс.

                У вас кстати есть статистика по количеству знаков в обьявлениях? Среднее, медиана?


                1. avivasyuta Автор
                  08.07.2022 18:24

                  У вас кстати есть статистика по количеству знаков в обьявлениях? Среднее, медиана?

                  Такая статистика у нас конечно же имеется.

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

                  Как раз именно знаками и оперирует пользователь. В особенности профессиональные пользователи, которые встречаются с такой механикой в любой системе.

                  Возьмите, например, копирайтеров, переводчиков и другие профессии, связанные с текстами в более классическом понимании. Они не берут оплату за страницу или слово. Объем работ и ценность считается по количеству знаков в тексте. Журналисты пишут статью на определенное количество знаков. Знак — универсальная едина измерения, потому что текст может быть воспроизведен на любом носителе, занимать разное количество страниц, экранов, чего угодно. Количество знаков в тексте останется неизменным.


              1. Stalker_RED
                08.07.2022 18:11

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

                Хранение ведь в цифровом виде, а не в графическом.


                1. avivasyuta Автор
                  08.07.2022 18:17

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


          1. dididididi
            09.07.2022 10:05

            А как насчёт тех пользователей, которым не нравятся куча эмодзи в тексте? Их интересы вы учли? Всё эти стрелочки вверх и восклицательные знаки безумно раздражают.


      1. dididididi
        09.07.2022 09:59
        +1

        Количество железа зависит от количества байт. И если эмодзи тратит 8 байт, то в varchar (1000) ваша эмодзи уже не влазит и нужно резервировать 8000 байт, на случай если какой нить потребитель наберёт всё объявление тяжёлыми эмодзи.

        Во вторых, количество железа зависит от среднего размера объявления, а не максимального.

        И проблема выеденного яйца не стоит, ну набрал кто-то 442 символа, а не 450 и что? Т. е. Он пальчиком пересчитал символы, и ещё в поддержку письмо написал? Да ему санитара вызывать надо.


  1. vshmdt
    09.07.2022 09:05

    Уже писали что с введением любых символов юникода получается новый вектор обхода проверки объявлений на дубли? Теперь можно спрятать аудио наркотики за символами которые будут обходить черный список. Про аналитику по тексту объявлений тоже можно переживать. Как с этими моментами решили поступить?


    1. avivasyuta Автор
      09.07.2022 09:11

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

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

      Поясните, каким образом это связано?


  1. dididididi
    09.07.2022 10:21

    Не в тему, конечно, на авито если поставить поиск в радиусе 1 км, то как в радиусе километра заканчивается, то следующее обяъявление сразу на камчатку, а потом в Белгороде. Да и сам фильтр скидывается. Указал радиус километр и стул, потом переделал на стул мягкий, он тебя сразу в новосиб отправляет. Вряд ли пользователь постоянно меняет своё положение? Можно предположить, читать он хотя бы одном городе остаётся? И если стул нужен в Москве то вряд ли ли стол нужен в Красноярске?

    И это реально бесит, а вот количество символов в описании - это ни разу не считал)


    1. avivasyuta Автор
      09.07.2022 13:30
      +1

      К сожалению, я не знаю, как в деталях работает поиск. Обязательно передам ваш комментарий коллегам.


  1. SkyHobbit
    09.07.2022 13:29

    А можно было вместо фразы "Введите 250 символов" написать "Введите примерно 250 символов" и вопросов бы не было. А если без шуток, статья очень интересная, спасибо.


    1. noodles
      09.07.2022 19:42

      Можно попробовать напрмер ещё вот так:
      Если есть в тексте код-поинт не из базовой плоскости - "втихаря" динамически накидывать в среднем +4 символа к стандартному ограничению. Типа по умолчанию разрешаем 250 символов.. но если есть один нестандартный код-поинт - разрешаем 254символа. Если присутствует два нестандартных кодпоинта - разрешаем 258символов, и т.д.


      1. avivasyuta Автор
        09.07.2022 20:32

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


  1. Chupaka
    10.07.2022 00:11

    Существует кодировка UTF-8, которая использует 8 бит на кодирование каждого символа. При помощи нее можно закодировать 256 различных знаков (2^8). Строки в этой кодировке занимают очень мало места: текст в 10 000 знаков будет занимать 10 000 байт или 9,77 КБ. Это позволяет оптимизировать использование папамяти.

    Что я пропустил? Или санитары из заголовка просто отвлеклись?