UTF-8 в PHP. Часть 1
Здравствуйте, этим постом я хотел бы попытаться приблизить светлое будущее, в котором все используют «кошерную» кодировку UTF-8. В частности это касается наиболее близкой мне среды – веба и языка программирования – PHP, а в конце серии мы подойдём к практической части и разработаем ещё одну велосипедную библиотеку.
1. Вступление
Для понимания дальнейшего текста начинающим нужно знать некоторые детали по кодировкам в целом. Подачу материала я постараюсь максимально упростить. Для незнающих ничего о побитовых операциях необходимо предварительно ознакомиться с материалами на википедии.
Начать нужно с понимания того, что компьютер работает с числами и хранить строку (и символ, как её часть) приходиться тоже в числовом виде. Для этих целей существуют кодировки. По сути это таблицы, в которых указано соответствие между числами и символами. Исторически сложилось, что основная кодировка ASCII содержит лишь контрольные коды и латинские символы, всего их 128 (127 – максимальное число, которое можно хранить в 7 битах).
Для того чтобы хранить и другие тексты на основе ASCII было создано много других кодировок, в которых добавили 8-ой бит. Они могут хранить уже до 256 символов, первые 128 с которых традиционно соответствовали ASCII, а вот в остальную часть каждый пихал всё, что ему хотелось. Так и получилось, что у каждого производителя операционных систем свои наборы кодировок, причём каждая удовлетворяла потребности лишь относительно узкого круга людей. Ситуацию ещё сильнее усложнили отсутствием общих стандартов, различать их алгоритмически стало невозможно и теперь это больше похоже на угадывание (об этом в следующих частях).
В итоге потребовался универсальный выход, кодировка, которая сможет хранить все возможные символы и будет учитывать различия в письме различных народов (например, направление письма). Поставленную задачу решили созданием Unicode, которая способна кодировать практически все системы письменности в мире одной кодировкой.
- полная совместимость с ASCII;
- её можно с высокой точностью отличить от других кодировок;
- каждый символ может занимать от 1 до 4 байт (в стандарте байты называют октетами; внимание, я могу заменять эти термины друг другом!) в зависимости от числового значения, которое нужно хранить.
Хотелось бы подробнее остановиться на последнем пункте. Это значит, что если раньше можно было выполнять простое преобразование по таблице и записывать результат, то сейчас определён и метод сохранения этого результата, в зависимости от разрядности, которая требуется для его хранения. На примере принцип хранения вы можете увидеть в таблице (x – хранимые биты данных):
Бит | Максимальное хранимое значение | 1 октет | 2 октет | 3 октет | 4 октет |
---|---|---|---|---|---|
Начальный октет | Продолжающие октеты | ||||
7 | U+007F | 0xxxxxxx | |||
11 | U+07FF | 110xxxxx | 10xxxxxx | ||
16 | U+FFFF | 1110xxxx | 10xxxxxx | 10xxxxxx | |
21 | U+10FFFF (по стандарту, но реально U+1FFFFF) | 11110xxx | 10xxxxxx | 10xxxxxx | 10xxxxxx |
Легко заметить, что в старших битах начального октета всегда находится счётчик, указывающий на количество байт в последовательности – это количество ведущих единиц, после которых идёт ноль. Обратите внимание: если октет лишь один, то ведущая единица не указывается, благодаря чему начальные октеты легко отличить от продолжающих.
Для примера давайте посмотрим как строка «Привет Hi» будет выглядеть в кодировке UTF-8.
Шаг первый. Перевести каждый символ в его числовое представление (я буду использовать шестнадцатеричную систему исчисления) по таблице.
Привет Hi = 0x041F 0x0440 0x0438 0x0432 0x044D 0x0442 0x0020 0x0048 0x0069
Не забываем, что пробел – тоже символ.
Шаг второй. Конвертировать числа из шестнадцатеричной в двоичную систему. Используем калькулятор Windows 7 (в режиме программиста).
0x041F = 0000 0100 0001 1111
0x0440 = 0000 0100 0100 0000
0x0438 = 0000 0100 0011 1000
0x0432 = 0000 0100 0011 0010
0x0435 = 0000 0100 0011 0101
0x0442 = 0000 0100 0100 0010
0x0020 = 0010 0000
0x0048 = 0100 1000
0x0069 = 0110 1001
Для наглядности я добавил нули в старшие разряды. Обратите внимание: символы могут занимать разное количество байт.
Шаг третий. Перевести числовые представления в последовательности октетов UTF-8.
0x041F = 100 0001 1111 = 110xxxxx 10xxxxxx = 11010000 10011111
0x0440 = 100 0100 0000 = 110xxxxx 10xxxxxx = 11010001 10000000
0x0438 = 100 0011 1000 = 110xxxxx 10xxxxxx = 11010000 10111000
0x0432 = 100 0011 0010 = 110xxxxx 10xxxxxx = 11010000 10110010
0x0435 = 100 0011 0101 = 110xxxxx 10xxxxxx = 11010000 10110101
0x0442 = 100 0100 0010 = 110xxxxx 10xxxxxx = 11010001 10000010
0x0020 = 010 0000 = 0xxxxxx = 00100000
0x0048 = 100 1000 = 0xxxxxx = 01001000
0x0069 = 110 1001 = 0xxxxxx = 01101001
Счётчики выделены жирным. Обратите внимание: символы с кодами до 0x0080 сохраняются без изменений, это и есть совместимость с ASCII. Ещё следует понимать, что UTF-8 будет занимать в 2 раза больше места (2 байта) для русскоязычного текста, чем Windows-1251, которая использует лишь 1 байт.
В качестве решения можно записать всю последовательность подряд (надеюсь без ошибок): «11010000 10011111 11010001 10000000 11010000 10111000 11010000 10110010 11010000 10110101 11010001 10000010 00100000 01001000 01101001».
Проверить решение можно кодом:
$tmp = » ;
foreach ( explode ( ‘ ‘ , ‘11010000 10011111 11010001 10000000 11010000 10111000 11010000 10110010 11010000 10110101 11010001 10000010 00100000 01001000 01101001’ ) as $octet ) <
$tmp .= chr ( bindec ( $octet ) ) ;
>
echo $tmp ;
- Определить количество октетов в 1-ом символе и сохранить это значение;
- От первого байта отбросить счётчик октетов, остаток сохранить;
- Если в последовательности более 1 октета сдвигать остаток после операции 2 на 6 бит влево и записывать в них информацию с младших 6 бит последующего октета;
- Повторять с 1 пункта до удовлетворения :).
Оптимизированный PHP код, который позволяет получать числовое представление символов и обратную операцию (полную версию опубликую в конце цикла):
- class String_Multibyte
- /**
* Возвращает десятеричное значение UTF-8 символа, первый октет которого находится на позиции $index в строке $char.
* Суррогатные коды, символы с приватных зон, BOM и 0x10FFFE-0x10FFFF вернут FALSE.
*
* [. ] Функция была оптимизирована, потому содержит избыточный код.
*
* @author Andrew Dryga , .
* @param string $char Строка с символом (символами).
* @param int &$index Аргумент указывает на октет, в котором необходимо начать вычисление значение для символа. После вызова будет хранить позицию последнего октета, принадлежащего указанному символу.
* @return int|false Десятерчиное значение символа или FALSE в случае обнаружения символа или байта, которые нужно проигнорировать.
*/ - public function getCodePoint( $char , & $index = 0 )
- // Получаем значение первого октета
- $octet1 = ord( $char [ $index ]);
- // Если оно попадает в диапазон ASCII кодов (имеет вид 0bbb bbbb), то возвращаем результат.
- if ( $octet1 >> 7 == 0x00 )
- return $octet1 ;
- > elseif ( $octet1 >> 6 != 0x02 )
- // Проверяем существование следующего октета
- if (! isset ( $char [++ $index ]))
- return false ;
- >
- // Получаем его значение
- $octet2 = ord( $char [ $index ]);
- // Проверяем его на валидность (должен иметь вид 10bb bbbb)
- if ( $octet2 >> 6 != 0x02 )
- — $index ;
- return false ;
- >
- // Оставляем только его нижние 6 бит
- $octet2 &= 0x3F ;
- // Проверяем счётчик и если октетов должно быть всего два, то формируем результат
- if ( $octet1 >> 5 == 0x06 )
- $result = ( $octet1 & 0x1F )
- // Результат должен быть в максимально сокращённой форме
- if ( 0x80 < $result )
- return $result ;
- >
- > else
- if (! isset ( $char [++ $index ]))
- return false ;
- >
- $octet3 = ord( $char [ $index ]);
- if ( $octet3 >> 6 != 0x02 )
- — $index ;
- return false ;
- >
- $octet3 &= 0x3F ;
- if ( $octet1 >> 4 == 0x0E )
- $result = ( $octet1 & 0x0F )
- // Проверяем минимальное значение; удаляем суррогаты, приватную зону и BOM
- if ( 0x800 < $result && !( 0xD7FF < $result && $result < 0xF900 ) && $result != 0xFEFF )
- return $result ;
- >
- > else
- if (! isset ( $char [++ $index ]))
- return false ;
- >
- $octet4 = ord( $char [ $index ]);
- if ( $octet4 >> 6 != 0x02 )
- — $index ;
- return false ;
- >
- $octet4 &= 0x3F ;
- if ( $octet1 >> 3 == 0x1E )
- $result = ( $octet1 & 0x07 )
- // Проверяем минимальное значение; Удаляем приватную зону и некоторые другие символы;
- // Удостовериваемся, что полученое значение не выходит за рамки зоны Unicode 10FFFF
- if ( 0x10000 < $result && $result < 0xF0000 )
- return $result ;
- >
- >
- >
- >
- return false ;
- >
- >
- /**
* Возвращает UTF-8 символ по его коду.
* [. ]
* @author ur001 , .
* @param string $codePoint Unicode character ordinal.
* @return string|FALSE UTF-8 символ или FALSE в случае ошибки.
*/ - public function getChar( $codePoint )
- if ( $codePoint < 0x80 )
- return chr( $codePoint );
- > elseif ( $codePoint < 0x800 )
- return chr( 0xC0 | $codePoint >> 6 ) . chr( 0x80 | $codePoint & 0x3F );
- > elseif ( $codePoint < 0x10000 )
- return chr( 0xE0 | $codePoint >> 12 ) . chr(
- 0x80 | $codePoint >> 6 & 0x3F ) . chr( 0x80 | $codePoint & 0x3F );
- > elseif ( $codePoint < 0x110000 )
- return chr( 0xF0 | $codePoint >> 18 ) . chr(
- 0x80 | $codePoint >> 12 & 0x3F ) . chr( 0x80 | $codePoint >> 6 & 0x3F ) . chr(
- 0x80 | $codePoint & 0x3F );
- > else
- return false ;
- >
- >
- >
Метод getChar() был взят с библиотеки Jevix, я всё-равно уже видел этот код, хорошо его запомнил и даже при его реализации по памяти было бы нечестно не упомянуть автора.
Вы же можете протестировать получившийся класс при помощи кода:
- // Создадим экземляр объекта
- $obj = new String_Multibyte ();
- // Сформируем строку наиболее удобным для теста способом
- $tmp = » ;
- foreach ( explode ( ‘ ‘ , ‘11010000 10011111 11010001 10000000 11010000 10111000 11010000 10110010 11010000 10110101 11010001 10000010 00100000 01001000 01101001’ ) as $octet )
- $tmp .= chr ( bindec ( $octet ) );
- >
- // Строим карту кодов символов
- $map = array ();
- $len = strlen ( $tmp );
- for ( $i = 0 ; $i < $len ; $i ++)
- if ( true == ( $result = $obj ->getCodePoint ( $tmp , $i )))
- $map [] = $result ;
- >
- >
- // Очищаем строку и восстанавливаем её с карты
- $tmp = » ;
- $count = count ( $map );
- for ( $i = 0 ; $i < $count ; $i ++)
- $tmp .= $obj ->getChar ( $map [ $i ] );
- >
- // Выводим восстановленную строку
- echo $tmp , ‘
‘ .EOL; - // Проверяем её на валидность (это самый простой способ)
- echo preg_match ( ‘#.#u’ , $tmp ) ? ‘Valid Unicode’ : ‘Unknown’ , ‘
‘ .EOL;
Я не старался писать самый красивый или правильный код для тестов, но при помощи него вы можете спокойно побитово менять значения символов и сразу видеть результат. Все невалидные последовательности будут проигнорированы, выводимая строка всегда валидна, но это ещё далеко не всё.
Чтобы быть уверенным, что текст не содержит ничего лишнего нужно удалить с него ненужные (непечатные, нарушающие разметку, неопределённые, суррогатные и т.п.) символы и провести нормализацию, об этом в следующей части.
P.S.:
Дальше будет про нормализацию, безопасность, определение кодировок и работу с UTF-8 в PHP.
Ссылки:
Установка локали UTF-8 в PHP
В любом PHP приложении нужно настраивать локаль и кодировку вне зависимости от настроек сервера. Это предотвратит неверное отображение и работу сайта при переезде на другой хостинг и других ситуаций.
Setlocale
Основная функция, в случаи успеха возвращает устанавливаемое значение или FALSE . Влияет на строковые функции, даты и т.д.
setlocale(LC_ALL, 'ru_RU.utf8');
Возможен вариант:
Вместо LC_ALL можно указать отдельную категорию функций, на которые будет влиять локаль:
- LC_COLLATE – функции сравнения строк,
- LC_CTYPE – функции преобразования и классификации строк,
- C_MONETARYL – для функции localeconv(),
- LC_NUMERIC – задает символ десятичного разделения,
- LC_TIME – форматирование даты/времени,
- LC_MESSAGES – для системных сообщений.
MB_string
Настройка функций для работы с многобайтовыми строками.
mb_internal_encoding('UTF-8'); mb_regex_encoding('UTF-8'); mb_http_output('UTF-8'); mb_language('uni');
Часовой пояс
От него зависит результат работы функций с датами, подробнее о настройке временной зоны.
date_default_timezone_set('Europe/Moscow');
Кодировка контента
Ещё можно явно указать в какой кодировке передается контент, отправив заголовок:
header('Content-type: text/html; charset=utf-8');
Код целиком
// Локаль. setlocale(LC_ALL, 'ru_RU.utf8'); mb_internal_encoding('UTF-8'); mb_regex_encoding('UTF-8'); mb_http_output('UTF-8'); mb_language('uni'); header('Content-type: text/html; charset=utf-8'); date_default_timezone_set('Europe/Moscow');