Статья
Версия для печати
Обсудить на форуме
Кодировки символов и как с ними бороться в PHP и JavaScript.


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

Какие кодировки бывают?

Кодировки символов делятся на два типа: универсальные (единые для всех языков) и узкого назначения. Первая группа называется unicode (utf, ucs).
Кодировки бывают 8-и, 16-и и 32-х битные. Бывают еще 7-и битные, но на сегодня это экзотика.
  • 32-х битные символы - utf-32 - могут потребоваться разве что для поддержки символов больше 65535 - по документации там значатся редкие языки и различные символы. Например, древнеперсидские символы, музыкальные ноты, всякие иероглифы. Вероятность того, что нам с вами придется с ними работать, мала.
  • 16-и битные символы - utf-16 - в сети встречаются. Обычно, это восточные сайты с иероглифами. Utf-16 бывает двух видов: BE (Big Endian) и LE (Little Endian). Они различаются порядком расположения байт в символе. Кстати, к utf-32 это тоже относится.
  • 8-ми битные кодировки - самые распространенные и самые запутанные.

Самая "стандартная" из них - iso-8859-1 (она же latin1, она же ascii). Ее первые 127 символов присутствуют во всех остальных стандартизированных кодировках.
Среди русских 8-и битных кодировок встречаются: windows-1251 (она же cp1251), koi8-r (замечательна тем, что если обнулить у всех байт старший бит, то русский текст все равно можно прочесть), cp866 (она же OEM, она же DOS-кодировка, она же альтернативная кодировка ГОСТа) и iso-8859-5 (в Интернете ни разу не встречал).
Также есть 8-и битная кодировка для Unicode - utf-8. Коды символов больше 127 описываются в ней несколькими байтами - от 2 до 4. Символы ascii будут занимать по одному байту на символ. Русские буквы, как и буквы многих европейских языков с кодами символов меньше 2048, будут занимать по два байта. Коды до 65535 вписываются в три байта, а прочие - 4 байта. Подробно о utf-8 можно прочесть здесь: http://ru.wikipedia.org/wiki/UTF-8 .
Кодировок море и как тут можно не запутаться... Тем более, что большинство сайтов используют 8-и битные кодировки (но не utf-8), и они не всегда совпадают с кодировкой по умолчанию в браузере пользователя.

Как сервер может узнать, какую кодировку предпочитает браузер?

Очень просто: в протоколе http предусмотрен параметр заголовка запроса Accept-charset. В нем браузер может передать желаемые и допустимые кодировки. Дело сервера - подстроиться (если сможет), либо сообщить свою кодировку.
Если браузер ее не сообщает (этим грешит IE, когда выполняется ввод ссылки вручную в адресную строку браузера), сервер, как правило, использует свою кодировку по умолчанию или ту, что сообщит браузеру серверное приложение.

Как браузер может узнать кодировку полученного документа?

Под документом тут я понимаю html, xml, а также документы, которые можно отнести к plain text (чистый текст).
Способов несколько. Прежде всего, сервер может сообщить об этом в поле для дополнительной информации в параметре заголовка ответа Content-type. Например:

Код:
Content-type: text/html; charset=windows-1251

Для plain text это - единственный способ. Для html и xml кодировку еще можно сообщить внутри документа. Для xml - в атрибуте encoding тега <?xml >, для html - в теге meta. Примеры:

Код:
<?xml version="1.0" encoding="windows-1251"?>
<meta http-equiv="content-type" content="text/html; charset=windows-1251"/>

Если кодировка указана в двух местах - в http-заголовке и в самом документе, то браузер должен использовать указанную в документе.
Если данных о кодировке нет, то браузер либо выбирает кодировку, установленную в нем по умолчанию, либо пытается определить ее автоматически, по содержимому.

Какие проблемы бывают у серверных скриптов?

Прежде всего - понять, в какой кодировке прислал данные браузер. Вообще то, об этом можно не думать - пусть браузер думает, прежде чем посылать, да и пользователю проще переключить кодировку при ощущении несовпадения желаемого и наблюдаемого. Потому что php-скрипту не передается никакой информации о кодировках.
Например, если запрос сделан по методу POST, то в заголовке должен быть параметр Content-type, в котором может быть указана кодировка посланных данных, но в массиве $_SERVER она никак не отображается. При запросе GET информации о кодировках вообще не предусмотрено.
Единственное, что видит скрипт - желаемые кодировки, переданные в параметре http-заголовка Accept-encoding, скопированные в переменную $_SERVER['HTTP_ACCEPT_CHARSET']. Можно попытаться определить кодировку автоматически, но в этой статье я не ставил такой задачи.
Обычно процесс происходит так:

  • Пользователь вводит в браузере строку запроса или переходит по ссылке. При этом происходит запрос GET. Кодировку присланных данных можно предположить по $_SERVER['HTTP_ACCEPT_CHARSET'], попытаться определить автоматически или использовать кодировку скрипта по умолчанию.
  • Сервер в ответе указывает кодировку, и браузер должен ее принять, а иначе все на совести браузера и пользователя.
  • Последующие GET и POST запросы к тому же серверу браузер должен делать в кодировке документа. Если, к примеру, пользователь, получив страницу в кодировке koi8-r, сам переключит браузер на использование windows-1251, и введет в форме текст, содержащий не ascii символы, и пошлет это на сервер, то серверный скрипт, если не поддерживает автоопределения, обработает данные в своей кодировке, и результатом будут кракозябры.

Кодировку для отсылаемых браузеру данных обычно выбирают одну - которая используется в серверном скрипте. Реже, она соответствует кодировке принятых от браузера данных или пользователь ранее перешел по специально предложенным ссылкам, по которым сервер понимает желаемую кодировку.
Для поддержки нескольких кодировок можно держать на сервере несколько копий скриптов в разных кодировках, но это неудобно в поддержке, т.к. при внесении изменений в один файл придется создать его копии в других кодировках. Да и данные БД придется перекодировать.
Есть решение лучше. Скрипт и БД работают только с одной кодировкой. Входные данные придется перекодировать, перебрав массивы $_GET и $_POST и пропустив каждый элемент массива через iconv(). Для перекодирования выходных данных придется их пропустить через iconv(). Это можно сделать следующими способами:

  • весь вывод помещать в переменную (не всегда возможно), а в конце работы перекодировать ее и вывести;
  • установить буферизацию вывода (ob_start()) в начале скрипта, а в конце забрать оттуда данные (ob_get_clean()), перекодировать и вывести;
  • установить буферизацию с обработчиком, который будет на лету получать вывод, перекодировать и выводить.

Следующая проблема - кодированные символы, полученные сервером по методу GET и POST.
В строке запроса (query string - часть url после знака "?") могут быть символы: в 8-и битной кодировке (очень не желательно), в виде шестнадцатеричной записи "%XX" (php это автоматически декодирует в 8-и битную и, если может, разбивает на части и помещает в массив $_GET) и в виде шестнадцатеричной unicode-записи "%uXXXX" (такого вида запись выдает функция escape() в JavaScript). Последнюю запись php (по крайней мере 4.3.x) не обрабатывает и ее придется декодировать в скрипте.

Код:
<?php
function unicodeUrlDecode($url$encoding "")
{
    if (
$encoding == &#39;&#39;)
    
{
if (isset($_SERVER[&#39;HTTP_ACCEPT_CHARSET&#39;]))
{
    preg_match(&#39;/^\s*([-\w]+?)([,;\s]|$)/&#39;, $_SERVER[&#39;HTTP_ACCEPT_CHARSET&#39;], $a);
    $encoding strtoupper($a[1]);
}
else
    $encoding = &#39;CP1251&#39;; // default
    
}

    
preg_match_all(&#39;/%u([[:xdigit:]]{4})/&#39;, $url, $a);
    
foreach ($a[1] as $unicode)
    {
$num hexdec($unicode);
$str = &#39;&#39;;

// UTF-16(32) number to UTF-8 string
if ($num 0x80)
    $str chr($num);
else if ($num 0x800)
    $str chr(0xc0 | (($num 0x7c0) >> 6)) .
chr(0x80 | ($num 0x3f));
else if ($num 0x10000)
    $str chr(0xe0 | (($num 0xf000) >> 12)) .
chr(0x80 | (($num 0xfc0) >> 6)) .
chr(0x80 | ($num 0x3f));
else
    $str chr(0xf0 | (($num 0x1c0000) >> 18)) .
chr(0x80 | (($num 0x3f000) >> 12)) .
chr(0x80 | (($num 0xfc0) >> 6)) .
chr(0x80 | ($num 0x3f));

$str iconv("UTF-8""$encoding//IGNORE"$str);
$url str_replace(&#39;%u&#39;.$unicode, $str, $url);
    
}

    return 
urldecode($url);
}
?>


Дополнительный параметр в IGNORE позволяет удалять символы, которые нет возможности перевести в указанную кодировку. Также сделана попытка определить желаемую кодировку по $_SERVER['HTTP_ACCEPT_CHARSET'].
При передаче данных на сервер методом POST браузер должен использовать кодировку, используемую на странице. Т.е., теоретически, кодировки браузера и сервера должны совпадать. Иногда браузер кодирует посылаемые данные в виде записей html entity - "&#N;", где N - от 1 до 6 десятичных цифр, либо в шестнадцатеричном формате - "&#xXXXX;".
Когда и при каких условиях это происходит, я пока не понял, но способ борьбы с такими записями есть - html_entity_decode(). Правда, у него есть один существенный недостаток – в данных, полученных от браузера, php уже заменил некоторые entity (& > < " '). Может сложиться ситуация, когда в этом, уже декодированном тексте, встретится такая же последовательность символов и перекодировка произойдет еще раз. На этот случай я написал программу, которая декодирует только entity, кодирующие unicode-символы.

Код:
<?php
$GLOBALS
[&#39;_HTMLEntitiesDecode_encoding&#39;] = &#39;&#39;;
$GLOBALS[&#39;_HTMLEntitiesDecode_replacements&#39;] =
    
array(
160 => &#39;"&#39;,
171 => &#39;"&#39;,
187 => &#39;"&#39;,
8220 => &#39;"&#39;,
8221 => &#39;"&#39;,
8211 => &#39;-&#39;,
8212 => &#39;-&#39;,
9552 => &#39;-&#39;
    
);

function 
_HTMLEntitiesDecode_iconv($num)
{
    
$encoding =  $GLOBALS[&#39;_HTMLEntitiesDecode_encoding&#39;];

    // UTF-16(32) number to UTF-8 string
    
if ($num 0x80)
$str chr($num);
    else if (
$num 0x800)
$str chr(0xc0 | (($num 0x7c0) >> 6)) .
    chr(0x80 | ($num 0x3f));
    else if (
$num 0x10000)
$str chr(0xe0 | (($num 0xf000) >> 12)) .
    chr(0x80 | (($num 0xfc0) >> 6)) .
    chr(0x80 | ($num 0x3f));
    else
$str chr(0xf0 | (($num 0x1c0000) >> 18)) .
    chr(0x80 | (($num 0x3f000) >> 12)) .
    chr(0x80 | (($num 0xfc0) >> 6)) .
    chr(0x80 | ($num 0x3f));

    
$tmp = @iconv("UTF-8""$encoding//IGNORE"$str);
    if (
strlen($tmp) != &#39;&#39;) // если перекодировка прошла успешно
return $tmp;

    
// если такого символа в нашей кодировке не оказалось
    // но он широко распространен, то его можно заменить руками
    
if (isset($GLOBALS[&#39;_HTMLEntitiesDecode_replacements&#39;][$num]))
return $GLOBALS[&#39;_HTMLEntitiesDecode_replacements&#39;][$num];

    
return "&" "#$num;"// в крайнем случае, оставляем все как было
}

function 
_HTMLEntitiesDecode_cb($matches)
{
    
$str $matches[1];

    if (
$str{0} == &#39;x&#39;)
$str hexdec(substr($str1));
    else
$str preg_replace(&#39;/^0+(?=\d)/&#39;, &#39;&#39;, $str);

    
return _HTMLEntitiesDecode_iconv($str);
}

function 
HTMLEntitiesDecode($str$encoding = &#39;&#39;)
{
    if (
$encoding == &#39;&#39; && $GLOBALS[&#39;_HTMLEntitiesDecode_encoding&#39;] == &#39;&#39;)
    
{
if (isset($_SERVER[&#39;HTTP_ACCEPT_CHARSET&#39;]))
{
    preg_match(&#39;/^\s*([-\w]+?)([,;\s]|$)/&#39;, $_SERVER[&#39;HTTP_ACCEPT_CHARSET&#39;], $a);
    $encoding strtoupper($a[1]);
}
else
    $encoding = &#39;CP1251&#39;; // default. Обязательно в верхнем регистре - так требует iconv()
    
}
    
$GLOBALS[&#39;_HTMLEntitiesDecode_encoding&#39;] = $encoding;

    
return preg_replace_callback(&#39;/&#38;#(x[[:xdigit:]]{4}|\d{1,5});/&#39;, "_HTMLEntitiesDecode_cb", $str);
}
?>


У таких замен тоже есть один маленький недостаток. Как я уже говорил, связан он с тем, что php самостоятельно декодирует данные запроса, но не декодирует unicode-вставки. Если пользователь намеренно ввел в форме, к примеру, А (кириллическая буква "А" в юникоде), то php-скрипт не будет об этом знать. То же самое, если намеренно ввести %u0410 (та же буква "А") в строке адреса браузера.
Выход? Переходить на unicode-кодировки! Для этого нужно, чтобы utf-8 или utf-16 поддерживалась системой и php, а иначе функции работы со строками будут работать совсем не так, как хотелось.

Какие проблемы могут быть у клиентского скрипта на JavaScript?

Прежде всего, если в нем есть не-ascii символы, он должен быть в той же кодировке, что и страница, на которой он запущен.
Также, если скрипт динамически создает элементы, имеющие внешние ссылки, или скрипт использует AJAX с методом GET, то компоненты строки запроса, перед составлением их в url, нужно закодировать функцией escape(). Напоминаю, что при этом на сервере следует ожидать конструкций "%uXXXX".

Этой информации достаточно для понимания возможных проблем и их решений. Если же что-то показалось вам непонятным или у вас есть иные не менее интересные проблемы - прошу писать на наш форум: http://forum.shelek.ru .

Роман Чернышов (RXL) 25.06.2006.
Версия для печати
Обсудить на форуме