Как стать автором
Обновить

UTF-8 в PHP. Часть 1

Время на прочтение14 мин
Количество просмотров24K
Здравствуйте, этим постом я хотел бы попытаться приблизить светлое будущее, в котором все используют «кошерную» кодировку UTF-8. В частности это касается наиболее близкой мне среды – веба и языка программирования – PHP, а в конце серии мы подойдём к практической части и разработаем ещё одну велосипедную библиотеку.

1. Вступление


Для понимания дальнейшего текста начинающим нужно знать некоторые детали по кодировкам в целом. Подачу материала я постараюсь максимально упростить. Для незнающих ничего о побитовых операциях необходимо предварительно ознакомиться с материалами на википедии.

Начать нужно с понимания того, что компьютер работает с числами и хранить строку (и символ, как её часть) приходиться тоже в числовом виде. Для этих целей существуют кодировки. По сути это таблицы, в которых указано соответствие между числами и символами. Исторически сложилось, что основная кодировка ASCII содержит лишь контрольные коды и латинские символы, всего их 128 (127 – максимальное число, которое можно хранить в 7 битах).

Для того чтобы хранить и другие тексты на основе ASCII было создано много других кодировок, в которых добавили 8-ой бит. Они могут хранить уже до 256 символов, первые 128 с которых традиционно соответствовали ASCII, а вот в остальную часть каждый пихал всё, что ему хотелось. Так и получилось, что у каждого производителя операционных систем свои наборы кодировок, причём каждая удовлетворяла потребности лишь относительно узкого круга людей. Ситуацию ещё сильнее усложнили отсутствием общих стандартов, различать их алгоритмически стало невозможно и теперь это больше похоже на угадывание (об этом в следующих частях).

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

Наиболее популярной кодировкой в вебе стала UTF-8, которая обладает рядом весомых преимуществ:
  • полная совместимость с 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. От первого байта отбросить счётчик октетов, остаток сохранить;
  3. Если в последовательности более 1 октета сдвигать остаток после операции 2 на 6 бит влево и записывать в них информацию с младших 6 бит последующего октета;
  4. Повторять с 1 пункта до удовлетворения :).


Оптимизированный PHP код, который позволяет получать числовое представление символов и обратную операцию (полную версию опубликую в конце цикла):
Copy Source | Copy HTML
  1. class String_Multibyte
  2. {
  3.     /**<br/>     * Возвращает десятеричное значение UTF-8 символа, первый октет которого находится на позиции $index в строке $char.<br/>     * Суррогатные коды, символы с приватных зон, BOM и 0x10FFFE-0x10FFFF вернут FALSE.<br/>     * <br/>     * [...] Функция была оптимизирована, потому содержит избыточный код.<br/>     * <br/>     * @author Andrew Dryga <anddriga at gmail>, {@link http://andryx.habrahabr.ru}.<br/>     * @param  string    $char  Строка с символом (символами). <br/>     * @param int        &$index Аргумент указывает на октет, в котором необходимо начать вычисление значение для символа. После вызова будет хранить позицию последнего октета, принадлежащего указанному символу.<br/>     * @return int|false Десятерчиное значение символа или FALSE в случае обнаружения символа или байта, которые нужно проигнорировать.<br/>     */
  4.     public function getCodePoint($char, &$index =  0)
  5.     {
  6.         // Получаем значение первого октета
  7.         $octet1 = ord($char[$index]);
  8.         // Если оно попадает в диапазон ASCII кодов (имеет вид 0bbb bbbb), то возвращаем результат.
  9.         if ($octet1 >> 7 == 0x00) {
  10.             return $octet1;
  11.         } elseif ($octet1 >> 6 != 0x02) {
  12.             // Проверяем существование следующего октета
  13.             if (!isset($char[++$index])) {
  14.                 return false;
  15.             }
  16.             // Получаем его значение
  17.             $octet2 = ord($char[$index]);
  18.             // Проверяем его на валидность (должен иметь вид 10bb bbbb)
  19.             if ($octet2 >> 6 != 0x02) {
  20.                 --$index;
  21.                 return false;
  22.             }
  23.             // Оставляем только его нижние 6 бит
  24.             $octet2 &= 0x3F;
  25.  
  26.             // Проверяем счётчик и если октетов должно быть всего два, то формируем результат
  27.             if ($octet1 >> 5 == 0x06) {
  28.                 $result = ($octet1 & 0x1F) << 6 | $octet2;
  29.                 // Результат должен быть в максимально сокращённой форме
  30.                 if (0x80 < $result) {
  31.                     return $result;
  32.                 }
  33.             } else {
  34.                 if (!isset($char[++$index])) {
  35.                     return false;
  36.                 }
  37.  
  38.                 $octet3 = ord($char[$index]);
  39.                 if ($octet3 >> 6 != 0x02) {
  40.                     --$index;
  41.                     return false;
  42.                 }
  43.                 $octet3 &= 0x3F;
  44.  
  45.                 if ($octet1 >> 4 == 0x0E) {
  46.                     $result = ($octet1 & 0x0F) << 12 | $octet2 << 6 | $octet3;
  47.                     // Проверяем минимальное значение; удаляем суррогаты, приватную зону и BOM
  48.                     if (0x800 < $result && !(0xD7FF < $result && $result < 0xF900) && $result != 0xFEFF) {
  49.                         return $result;
  50.                     }
  51.                 } else {
  52.                     if (!isset($char[++$index])) {
  53.                         return false;
  54.                     }
  55.  
  56.                     $octet4 = ord($char[$index]);
  57.                     if ($octet4 >> 6 != 0x02) {
  58.                         --$index;
  59.                         return false;
  60.                     }
  61.                     $octet4 &= 0x3F;
  62.  
  63.                     if ($octet1 >> 3 == 0x1E) {
  64.                         $result = ($octet1 & 0x07) << 18 | $octet2 << 12 | $octet3 << 6 | $octet4;
  65.                         // Проверяем минимальное значение; Удаляем приватную зону и некоторые другие символы; 
  66.                         // Удостовериваемся, что полученое значение не выходит за рамки зоны Unicode 10FFFF
  67.                         if (0x10000 < $result && $result < 0xF0000) {
  68.                             return $result;
  69.                         }
  70.                     }
  71.                 }
  72.             }
  73.             return false;
  74.         }
  75.     }
  76.  
  77.  
  78.     /**<br/>     * Возвращает UTF-8 символ по его коду.<br/>     * [...]<br/>     * @author ur001 <ur001ur001@gmail.com>, {@link http://ur001.habrahabr.ru}.<br/>     * @param string $codePoint Unicode character ordinal.<br/>     * @return string|FALSE UTF-8 символ или FALSE в случае ошибки.<br/>     */
  79.     public function getChar($codePoint)
  80.     {
  81.         if ($codePoint < 0x80) {
  82.             return chr($codePoint);
  83.         } elseif ($codePoint < 0x800) {
  84.             return chr(0xC0 | $codePoint >> 6) . chr(0x80 | $codePoint & 0x3F);
  85.         } elseif ($codePoint < 0x10000) {
  86.             return chr(0xE0 | $codePoint >> 12) . chr(
  87.             0x80 | $codePoint >> 6 & 0x3F) . chr(0x80 | $codePoint & 0x3F);
  88.         } elseif ($codePoint < 0x110000) {
  89.             return chr(0xF0 | $codePoint >> 18) . chr(
  90.             0x80 | $codePoint >> 12 & 0x3F) . chr(0x80 | $codePoint >> 6 & 0x3F) . chr(
  91.             0x80 | $codePoint & 0x3F);
  92.         } else {
  93.             return false;
  94.         }
  95.     }
  96. }

Метод getChar() был взят с библиотеки Jevix, я всё-равно уже видел этот код, хорошо его запомнил и даже при его реализации по памяти было бы нечестно не упомянуть автора.

Вы же можете протестировать получившийся класс при помощи кода:
Copy Source | Copy HTML
  1. // Создадим экземляр объекта
  2. $obj = new String_Multibyte ();
  3. // Сформируем строку наиболее удобным для теста способом
  4. $tmp = '';
  5. foreach ( explode ( ' ', '11010000 10011111 11010001 10000000 11010000 10111000 11010000 10110010 11010000 10110101 11010001 10000010 00100000 01001000 01101001' ) as $octet ) {
  6.     $tmp .= chr ( bindec ( $octet ) );
  7. }
  8. // Строим карту кодов символов
  9. $map = array ();
  10. $len = strlen ( $tmp );
  11. for($i =  0; $i < $len; $i ++) {
  12.     if (true == ($result = $obj->getCodePoint ( $tmp, $i ))) {
  13.             $map [] = $result;
  14.     }
  15. }
  16. // Очищаем строку и восстанавливаем её с карты
  17. $tmp = '';
  18. $count = count ( $map );
  19. for($i =  0; $i < $count; $i++) {
  20.     $tmp .= $obj->getChar ( $map[$i] );
  21. }
  22. // Выводим восстановленную строку
  23. echo $tmp, '<br />'.EOL;
  24. // Проверяем её на валидность (это самый простой способ)
  25. echo preg_match ( '#.{1}#u', $tmp ) ? 'Valid Unicode' : 'Unknown', '<br />'.EOL;
  26.  
Я не старался писать самый красивый или правильный код для тестов, но при помощи него вы можете спокойно побитово менять значения символов и сразу видеть результат. Все невалидные последовательности будут проигнорированы, выводимая строка всегда валидна, но это ещё далеко не всё.

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

P.S.:
Дальше будет про нормализацию, безопасность, определение кодировок и работу с UTF-8 в PHP.

Ссылки:
Теги:
Хабы:
Всего голосов 62: ↑47 и ↓15+32
Комментарии57

Публикации

Истории

Работа

PHP программист
147 вакансий

Ближайшие события