Здравствуйте, этим постом я хотел бы попытаться приблизить светлое будущее, в котором все используют «кошерную» кодировку UTF-8. В частности это касается наиболее близкой мне среды – веба и языка программирования – PHP, а в конце серии мы подойдём к практической части и разработаем ещё одну велосипедную библиотеку.
Для понимания дальнейшего текста начинающим нужно знать некоторые детали по кодировкам в целом. Подачу материала я постараюсь максимально упростить. Для незнающих ничего о побитовых операциях необходимо предварительно ознакомиться с материалами на википедии.
Начать нужно с понимания того, что компьютер работает с числами и хранить строку (и символ, как её часть) приходиться тоже в числовом виде. Для этих целей существуют кодировки. По сути это таблицы, в которых указано соответствие между числами и символами. Исторически сложилось, что основная кодировка ASCII содержит лишь контрольные коды и латинские символы, всего их 128 (127 – максимальное число, которое можно хранить в 7 битах).
Для того чтобы хранить и другие тексты на основе ASCII было создано много других кодировок, в которых добавили 8-ой бит. Они могут хранить уже до 256 символов, первые 128 с которых традиционно соответствовали ASCII, а вот в остальную часть каждый пихал всё, что ему хотелось. Так и получилось, что у каждого производителя операционных систем свои наборы кодировок, причём каждая удовлетворяла потребности лишь относительно узкого круга людей. Ситуацию ещё сильнее усложнили отсутствием общих стандартов, различать их алгоритмически стало невозможно и теперь это больше похоже на угадывание (об этом в следующих частях).
В итоге потребовался универсальный выход, кодировка, которая сможет хранить все возможные символы и будет учитывать различия в письме различных народов (например, направление письма). Поставленную задачу решили созданием Unicode, которая способна кодировать практически все системы письменности в мире одной кодировкой.
Наиболее популярной кодировкой в вебе стала UTF-8, которая обладает рядом весомых преимуществ:
Хотелось бы подробнее остановиться на последнем пункте. Это значит, что если раньше можно было выполнять простое преобразование по таблице и записывать результат, то сейчас определён и метод сохранения этого результата, в зависимости от разрядности, которая требуется для его хранения. На примере принцип хранения вы можете увидеть в таблице (x – хранимые биты данных):
Легко заметить, что в старших битах начального октета всегда находится счётчик, указывающий на количество байт в последовательности – это количество ведущих единиц, после которых идёт ноль. Обратите внимание: если октет лишь один, то ведущая единица не указывается, благодаря чему начальные октеты легко отличить от продолжающих.
Для примера давайте посмотрим как строка «Привет 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».
Проверить решение можно кодом:
Чтобы произвести обратную операцию в коде нам необходимо (упрощено):
Оптимизированный PHP код, который позволяет получать числовое представление символов и обратную операцию (полную версию опубликую в конце цикла):
Метод getChar() был взят с библиотеки Jevix, я всё-равно уже видел этот код, хорошо его запомнил и даже при его реализации по памяти было бы нечестно не упомянуть автора.
Вы же можете протестировать получившийся класс при помощи кода:
Чтобы быть уверенным, что текст не содержит ничего лишнего нужно удалить с него ненужные (непечатные, нарушающие разметку, неопределённые, суррогатные и т.п.) символы и провести нормализацию, об этом в следующей части.
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 на 6 бит влево и записывать в них информацию с младших 6 бит последующего октета;
- Повторять с 1 пункта до удовлетворения :).
Оптимизированный PHP код, который позволяет получать числовое представление символов и обратную операцию (полную версию опубликую в конце цикла):
Copy Source | Copy HTML
- class String_Multibyte
- {
- /**<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/> */
- 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) << 6 | $octet2;
- // Результат должен быть в максимально сокращённой форме
- 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) << 12 | $octet2 << 6 | $octet3;
- // Проверяем минимальное значение; удаляем суррогаты, приватную зону и 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) << 18 | $octet2 << 12 | $octet3 << 6 | $octet4;
- // Проверяем минимальное значение; Удаляем приватную зону и некоторые другие символы;
- // Удостовериваемся, что полученое значение не выходит за рамки зоны Unicode 10FFFF
- if (0x10000 < $result && $result < 0xF0000) {
- return $result;
- }
- }
- }
- }
- return false;
- }
- }
-
-
- /**<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/> */
- 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, я всё-равно уже видел этот код, хорошо его запомнил и даже при его реализации по памяти было бы нечестно не упомянуть автора.
Вы же можете протестировать получившийся класс при помощи кода:
Copy Source | Copy HTML
- // Создадим экземляр объекта
- $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, '<br />'.EOL;
- // Проверяем её на валидность (это самый простой способ)
- echo preg_match ( '#.{1}#u', $tmp ) ? 'Valid Unicode' : 'Unknown', '<br />'.EOL;
-
Я не старался писать самый красивый или правильный код для тестов, но при помощи него вы можете спокойно побитово менять значения символов и сразу видеть результат. Все невалидные последовательности будут проигнорированы, выводимая строка всегда валидна, но это ещё далеко не всё.Чтобы быть уверенным, что текст не содержит ничего лишнего нужно удалить с него ненужные (непечатные, нарушающие разметку, неопределённые, суррогатные и т.п.) символы и провести нормализацию, об этом в следующей части.