
Начнём с устройства UTF-8. Латиница занимает в нём один байт, поэтому программисты десятилетиями привыкали считать, что символ и байт — одно и то же. Кириллица устроена иначе: каждая русская буква занимает два байта. Например, строчная «х» записывается парой D1 и 85, и по отдельности эти байты ничего не значат. Если от пары оторвать вторую половину, первая превратится в некорректную последовательность, а браузер честно покажет символ замены — тот самый ромбик. Так «слабовидящих» превращается в «слабовидящи» с ромбиком на конце.
Кто же отрывает половину буквы? Нередко виновником оказывается регулярное выражение с последовательностью \R. В документации она описана как «любой вариант перевода строки», и разработчики охотно берут её вместо ручного перечисления \n и \r\n: так короче и кажется надёжнее. Но среди вариантов, которые понимает \R, есть символ NEL с кодом 0x85. В режиме символов это один редкий управляющий символ, и всё в порядке. Однако без флага, включающего режим UTF-8, движок видит текст как набор отдельных байтов и принимает байт 0x85 внутри буквы «х» за настоящий перевод строки. Именно поэтому ошибка годами живёт в чужом коде: на латинице она ни разу себя не проявит.
Дальше всё происходит молча. Функция разбиения на строки находит «перевод строки» посреди слова, режет по этому байту и выбрасывает разделитель. Первая половина буквы остаётся в конце одной строки, вторая исчезает вместе с разделителем. Ни предупреждений, ни исключений: код отрабатывает штатно и просто возвращает слегка искалеченные данные. Заметнее всего это, когда «х» стоит последней в строке, ведь тогда страдает именно хвост заголовка; если же она в середине, строка раскалывается надвое, и пропадает гораздо больше. Отсюда ощущение случайности: пострадают только слова, где встретился нужный байт.
Коварство в том, что под подозрение первыми попадают совсем другие вещи. Начинают проверять кодировку исходных файлов, перекодировать их, искать в них битые байты, подбирать настройки вывода. Часть этих мер даже маскирует симптом: например, флаги, заменяющие некорректные последовательности символом замены, лишь прячут повреждение. Исходные данные при этом совершенно здоровы, и сколько ни чини файлы, результат не меняется, потому что текст портится не при хранении, а при разборе.
Лечится это просто. Во-первых, все регулярные выражения, работающие с русским текстом, стоит запускать с флагом /u: тогда движок оперирует символами и пополам их уже не разорвёт. Во-вторых, там, где важна точность, лучше отказаться от \R и \s и явно написать, какие переводы строки вы ожидаете: обычно достаточно \r\n, \n и \r. В-третьих, перед разбором полезно один раз проверить текст на корректность UTF-8 и очистить его, чтобы действительно битые данные не шли дальше. И наконец, осторожнее с функциями, которые работают по байтовым позициям, вроде substr, а также с trim, если в наборе символов лежат многобайтовые буквы: результат будет тем же.
Урок шире одной функции. Любая операция, которая считает, режет, сравнивает или разделяет текст по байтам, потенциально опасна для не латинских алфавитов, и это касается любого языка программирования. Так же ломаются обрезка строк до фиксированной длины, превью новостей и поиск по подстроке. Увидев ромбик на месте последней буквы, не спешите винить файлы и кодировки: сначала найдите место, где текст режется или чистится, и убедитесь, что оно понимает, что имеет дело с символами, а не с числами от нуля до двухсот пятидесяти пяти. Хорошая привычка — прогонять каждую новую функцию разбора текста через тестовую строку со всем русским алфавитом, включая редкие буквы вроде «ъ», «ё» и «щ»: проверка занимает минуту и ловит большинство подобных ошибок до того, как их увидят читатели.









