12k
All articles

Очистка текста, который пользователи вставляют в ваше приложение

Очищайте вставленный текст в веб-формах с помощью нормализации Unicode, удаления символов формата, сжатия пробелов и безопасной обработки ZWJ и NBSP.

OpenReplay Team
OpenReplay Team
Очистка текста, который пользователи вставляют в ваше приложение

Текст, вставленный в веб-форму, регулярно содержит невидимые символы, которые никто не вводил и которые не отрисовывает ни один шрифт: zero-width spaces, soft hyphens, byte order marks и non-breaking spaces. Все они попадают в вашу базу данных и незаметно ломают сравнение на точное совпадение, валидацию длины и поиск.

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

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

Ключевые выводы

  • Невидимые символы, приходящие вместе со вставленным текстом, относятся к общей категории Unicode Format, которая в регулярном выражении JavaScript записывается как \p{Cf}, поэтому одно совпадение по категории заменяет постоянно растущий список отдельных кодовых точек.
  • normalize("NFC") исправляет написание, а не невидимость: он делает два варианта кодирования одной и той же буквы с диакритикой равными при сравнении и оставляет zero-width space ровно там, где он был.
  • Non-breaking space U+00A0 — это пробельный символ, а не format-символ, поэтому он невредимым переживает вырезание по \p{Cf} и должен обрабатываться на шаге схлопывания пробелов.
  • U+200D ZERO WIDTH JOINER выполняет реальную работу: он связывает части эмодзи с несколькими персонажами в один глиф, а в арабском и индийских письменностях джойнеры несут орфографическое значение.
  • \p{...} означает свойство Unicode только тогда, когда у регулярного выражения установлен флаг u или v; без него это identity escape для буквального символа p.

Какие невидимые символы реально попадают в поле?

Вставленная строка из текстового процессора или rich-text редактора обычно смешивает видимые типографские замены с невидимыми format-символами. Фигурные кавычки, короткое и длинное тире, символ многоточия видимы и по большей части безобидны. Non-breaking space, soft hyphen, zero-width space и byte order mark не видны вообще — именно они ломают проверки на равенство.

Выведите строку в виде кодовых точек, и аргумент докажет себя сам:

function inspect(str) {
  return [...str]
    .map((ch) => ch.codePointAt(0))
    .filter((cp) => cp > 0x7f)
    .map((cp) => "U+" + cp.toString(16).toUpperCase().padStart(4, "0"));
}

const pasted = "\uFEFFSenior\u00A0Engineer\u200B, 2019\u20132024";

console.log(inspect(pasted)); // [ 'U+FEFF', 'U+00A0', 'U+200B', 'U+2013' ]
console.log(pasted.length);   // 28, for 26 characters a human would count

Разворачивайте строку через spread, а не вызывайте split(""): итератор строки возвращает целые кодовые точки, тогда как split("") разрезает астральные символы пополам на границе UTF-16.

Чтобы увидеть, что на самом деле содержится в имеющейся у вас строке, вставьте её в invisible character cleaner и посмотрите на кодовые точки.

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

Почему чёрный список zero-width символов постоянно растёт?

Класс символов из конкретных кодовых точек — это не плохо написанное решение, а решение неправильной формы, потому что в нём может оказаться только то, о чём кто-то уже завёл баг. Вы удаляете U+200B после первого обращения, добавляете U+FEFF, когда ломается импорт CSV, добавляете U+00AD, когда перестаёт находиться фамилия через дефис, и класс продолжает расти, потому что он перечисляет элементы множества вместо того, чтобы назвать само множество.

У этого множества есть имя. Zero-width space (U+200B), zero-width non-joiner (U+200C), zero-width joiner (U+200D), soft hyphen (U+00AD) и byte order mark (U+FEFF) — все они имеют General_Category=Cf согласно Unicode Character Database, и эти назначения остаются стабильными на протяжении многих версий стандарта. Non-breaking space U+00A0 в это множество не входит: у него категория Zs, space separator, — именно поэтому вырезание по категории само по себе оставляет на месте самый распространённый артефакт текстовых процессоров.

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

Решение — один проход из четырёх упорядоченных шагов: нормализовать кодирование, удалить format-символы, схлопнуть все варианты пробельных символов в обычный пробел, затем обрезать края.

const FORMAT_CHARS = /[\p{Cf}--[\u200C\u200D]]/gv;
const SPACE_RUN = /[\p{Zs}\t\n\r]+/gu;

export function cleanPastedText(input) {
  return input
    .normalize("NFC")               // one canonical spelling per character
    .replace(FORMAT_CHARS, "")      // BOM, zero-width space, soft hyphen, bidi controls
    .replace(SPACE_RUN, " ")        // NBSP, thin spaces, tabs, newlines -> one space
    .trim();
}

Уберите \n\r из SPACE_RUN, если поле — это textarea, где переводы строк являются содержимым.

Две вещи здесь заслуживают отдельного внимания. \p{...} имеет своё Unicode-значение, только когда регулярное выражение работает в Unicode-aware режиме; уберите и u, и v — и движок прочитает \p как экранированную литеральную p, так что шаблон скомпилируется, выполнится и не совпадёт ни с чем из задуманного. А шаг схлопывания использует явный класс, построенный на \p{Zs}, а не \s, — так прямо в месте вызова видно, что U+00A0 учтён.

Порядок имеет значение. normalize("NFC") решает вопрос кодирования, а не невидимости, поэтому он оставляет zero-width space на месте, чтобы его обработал шаг вырезания. Вырезание до схлопывания означает, что byte order mark будет удалён, а не превращён в лишний пробел. А обрезка в конце убирает ведущий пробел, оставшийся там, где удалённый format-символ соседствовал с пробелом.

Что не нужно вырезать

Слепое удаление всех format-символов портит реальный контент. U+200D ZERO WIDTH JOINER — это символ, который связывает эмодзи в один глиф: в стандарте эмодзи Unicode строка становится emoji ZWJ sequence именно благодаря наличию джойнера, так что удаление джойнеров оставляет несколько глифов там, где был один.

const family = "👨‍👩‍👧";

console.log([...family].length);                          // 5
console.log([...family.replace(/\p{Cf}/gu, "")].length);  // 3 -> 👨👩👧

Джойнеры — не украшение и за пределами эмодзи. Пишущие на арабском ставят non-joiner между двумя буквами, чтобы они не сливались так, как это происходит в курсивном письме, а базовая спецификация Unicode предупреждает, что текст, лишённый этих управляющих символов, либо говорит нечто иное, либо перестаёт иметь смысл. В деванагари ZWJ после вирамы выбирает полуформу согласной вместо полной лигатуры. Правило такое: вырезайте те невидимые символы, которые не несут смысла в вашем поле, и сохраняйте те, что выполняют структурную работу.

Именно это и выражает [\p{Cf}--[\u200C\u200D]]: флаг v добавляет операторы множеств в классы символов, а -- — это оператор вычитания. В среде выполнения без поддержки unicodeSets эквивалент в режиме u — это /(?![\u200C\u200D])\p{Cf}/gu. Не устанавливайте оба флага на одном регулярном выражении; они взаимоисключающие.

Почему следует использовать NFC, а не NFKC?

NFC разрешает два способа, которыми Unicode может записать один и тот же символ, и больше не меняет ничего. NFKC идёт дальше и переписывает символы совместимости: лигатура ff превращается в две f, а обведённая Ⓓ — в обычную D, как показывают примеры к normalize(). Это решение, изменяющее содержимое, определённое в приложении Unicode о нормализации как совместимость, а не каноническая эквивалентность, и принимать его стоит осознанно, а не получать как побочный эффект очистки поля формы.

Одна связанная ловушка: удаление soft hyphen — задача вашего очистителя, а не normalize("NFKC"). Операция Unicode, которая отображает U+00AD в пустую строку, — это NFKC_Casefold, преобразование, отличное от того, что выполняет String.prototype.normalize("NFKC").

Запускайте очиститель и на сервере

Очистка в браузере — это любезность по отношению к тому, кто печатает; нормализация, от которой зависят ваша база данных и поисковый индекс, должна выполняться на сервере, потому что запрос можно отправить, вообще не загружая вашу страницу. Всё, что приходит через публичный API, импорт CSV, webhook или мобильный клиент, полностью обходит обработчик ввода, а одной неочищенной строки достаточно, чтобы уникальное ограничение или поиск по точному совпадению начали вести себя непоследовательно. Одна и та же функция работает и в Node, и в браузере, поэтому запускайте её на границе, где данные попадают в хранилище, а вызов на клиенте пусть будет быстрой обратной связью, а не гарантией.

Перестаньте добавлять кодовые точки в класс символов и начните называть категорию. Четыре строки, применённые в каждой точке входа, удаляют невидимые символы, не несущие смысла в вашем поле, и сохраняют те, что скрепляют настоящий текст. Напишите фикстуру, в которой смешаны комбинируемый диакритический знак, non-breaking space, soft hyphen, byte order mark, zero-width space и эмодзи с ZWJ, а затем проверьте, что ваш очиститель композирует первый, схлопывает второй в обычный пробел, удаляет следующие три и возвращает эмодзи без изменений.

Часто задаваемые вопросы

Удаляет ли trim non-breaking space или zero-width space?

trim удаляет пробельные символы и терминаторы строк только с обоих концов, а список пробельных символов ECMAScript включает non-breaking space U+00A0 и byte order mark U+FEFF, поэтому оба исчезают по краям. Он никогда не удаляет zero-width space U+200B, который является format-символом, а не пробельным, и никогда не трогает ни один из этих символов в середине строки.

Удаляет ли вырезание format-символов также селекторы вариаций эмодзи вроде U+FE0F?

Нет. Селекторы вариаций от U+FE00 до U+FE0F имеют General_Category Mn, nonspacing mark, а не Cf, поэтому вырезание по категории format оставляет их на месте, и эмодзи сохраняют задуманное отображение. Keep-список нужен только джойнерам. Расширение очистителя до вырезания марок заодно удалило бы селекторы вариаций и все комбинируемые диакритические знаки.

Удаляет ли вырезание format-символов также символы right-to-left override?

Да. Двунаправленные управляющие символы, включая U+200E, U+200F, embeddings и overrides от U+202A до U+202E, а также isolates от U+2066 до U+2069, имеют General_Category Cf, поэтому одно совпадение по категории удаляет их вместе с zero-width space. Это важно для отображаемых имён и имён файлов, где right-to-left override разворачивает отрисованный текст и может замаскировать расширение.

Почему вставленное значение не проходит maxlength, хотя выглядит достаточно коротким?

maxlength считает кодовые единицы UTF-16, а не видимые символы, поэтому каждый невидимый пассажир расходует бюджет: byte order mark или zero-width space стоит одну единицу, а эмодзи, составленный из суррогатной пары, — две. Очищайте значение до валидации его длины и считайте через spread-форму, если предполагается, что ограничение соответствует тому, что видят люди.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.