Как преобразовать non-shortest UTF-8 в однобайтную кодировку

У меня возникла необходимость восстановить 30 тысяч jpg-изображений, хранящихся в BLOB’е MySQL-базы. При рассмотрении проблемы в бинарном виде стало ясно, что кто-то при импорте дампа перепутал кодировки и дамп в utf-8 был импортирован в однобайтном виде. В результате изображения начинались не с каноничных «FF D8 FF E0», а с «C3 BF C3 98 C3 BF C3 A0», что и является формой тех же байтов в кодировке utf-8.

Казалось бы — сконвертировать их с помощью iconv() в однобайтную iso-8859-1 и всё. Но не тут-то было. iconv наотрез отказался конвертировать данные, ссылаясь на недопустимые символы utf-8!

Дальнейшее копание в бинарных кодах «испорченных» изображений показало, что в коде изображений содержатся так называемые «некратчайшие» формы utf-8 символов (non-shortest utf-8).

Дело в том, что кодировка utf-8 появилась как развитие кодировки unicode. Последняя, как вы помните, является двухбайтной и абсолютно каждый символ в ней записывается как 2 байта. Даже банальные символы латиницы. Это оказалось не совсем удобно, поскольку размер всех текстов мгновенно вырос в 2 раза. Поэтому решили, что нужно наиболее часто используемые символы (латиница и знаки препинания) записывать как и прежде в однобайтной кодировке, а для всех остальных символов (кириллицы, разных там тайских и китайских иероглифов и т.д.) использовать от 2 до 6 байтов — в зависимости от популярности этих символов. Этот способ кодирования и назвали кодировкой utf-8.

В итоге была разработан алгоритм, по которому любой символ Unicode может быть переведён в utf-8 и обратно. И всё было бы идеально, но у этого алгоритма имеется свойство, которое выражается в том, что некоторые символы Unicode могут быть записаны в utf-8-виде в нескольких вариантах. Например, «юникодовский» символ «00 73» (прописная латинская буква s) в коде utf-8 может быть представлен как

73 (кратчайшая форма)

C1 B3 (2 байта)

E0 81 B3 (3 байта)

F0 80 81 B3 (4 байта)

F8 80 80 81 B3 (5 байтов)

FC 80 80 80 81 B3 (6 байтов)

Любой из вариантов представляет один и тот же символ. Но вот разработчики utf-8 решили, что это не есть хорошо и вносит путаницу и нужно принять валидной только первую, кратчайшую форму, а остальные принять невалидными.

Именно по этой причине iconv отказывается конвертировать некратчайшие формы символов.

Пришлось для преобразования некратчайших форм использовать самописную функцию на PHP, которая делает из utf-8 оригинальные юникодовские символы. А затем из юникодовских символов путём нехитрого ремаппинга выделять однобайтные версии.

На самом деле большинство символов unicode, полученных из однобайтной кодировки, могут быть преобразованы в однобайтную версию путём банального обрубания старшего байта (который нулевой). Исключение составляют символы в диапазоне 0x80-0x9f, для которых пришлось написать небольшой ассоциативный массив.

В общем виде код приведён ниже. Функция utf8ToUnicode() преобразует utf-8 в двухбайтную кодировку. А код ниже — всё остальное.

/**
 * Takes an UTF-8 string and returns an array of ints representing the 
 * Unicode characters. Astral planes are supported ie. the ints in the
 * output can be > 0xFFFF. Occurrances of the BOM are ignored. Surrogates
 * are not allowed.
 *
 * Returns false if the input string isn't a valid UTF-8 octet sequence.
 */
function utf8ToUnicode(&$str)
{
  $mState = 0;     // cached expected number of octets after the current octet
                   // until the beginning of the next UTF8 character sequence
  $mUcs4  = 0;     // cached Unicode character
  $mBytes = 1;     // cached expected number of octets in the current sequence

  $out = array();

  $len = strlen($str);
  for($i = 0; $i < $len; $i++) {
    $in = ord($str{$i});
    if (0 == $mState) {
      // When mState is zero we expect either a US-ASCII character or a
      // multi-octet sequence.
      if (0 == (0x80 & ($in))) {
        // US-ASCII, pass straight through.
        $out[] = $in;
        $mBytes = 1;
      } else if (0xC0 == (0xE0 & ($in))) {
        // First octet of 2 octet sequence
        $mUcs4 = ($in);
        $mUcs4 = ($mUcs4 & 0x1F) << 6;
        $mState = 1;
        $mBytes = 2;
      } else if (0xE0 == (0xF0 & ($in))) {
        // First octet of 3 octet sequence
        $mUcs4 = ($in);
        $mUcs4 = ($mUcs4 & 0x0F) << 12;
        $mState = 2;
        $mBytes = 3;
      } else if (0xF0 == (0xF8 & ($in))) {
        // First octet of 4 octet sequence
        $mUcs4 = ($in);
        $mUcs4 = ($mUcs4 & 0x07) << 18;
        $mState = 3;
        $mBytes = 4;
      } else if (0xF8 == (0xFC & ($in))) {
        /* First octet of 5 octet sequence.
         *
         * This is illegal because the encoded codepoint must be either
         * (a) not the shortest form or
         * (b) outside the Unicode range of 0-0x10FFFF.
         * Rather than trying to resynchronize, we will carry on until the end
         * of the sequence and let the later error handling code catch it.
         */
        $mUcs4 = ($in);
        $mUcs4 = ($mUcs4 & 0x03) << 24;
        $mState = 4;
        $mBytes = 5;
      } else if (0xFC == (0xFE & ($in))) {
        // First octet of 6 octet sequence, see comments for 5 octet sequence.
        $mUcs4 = ($in);
        $mUcs4 = ($mUcs4 & 1) << 30;
        $mState = 5;
        $mBytes = 6;
      } else {
        /* Current octet is neither in the US-ASCII range nor a legal first
         * octet of a multi-octet sequence.
         */
        return false;
      }
    } else {
      // When mState is non-zero, we expect a continuation of the multi-octet
      // sequence
      if (0x80 == (0xC0 & ($in))) {
        // Legal continuation.
        $shift = ($mState - 1) * 6;
        $tmp = $in;
        $tmp = ($tmp & 0x0000003F) << $shift;
        $mUcs4 |= $tmp;

        if (0 == --$mState) {
          /* End of the multi-octet sequence. mUcs4 now contains the final
           * Unicode codepoint to be output
           *
           * Check for illegal sequences and codepoints.
           */

          // From Unicode 3.1, non-shortest form is illegal
          if (((2 == $mBytes) && ($mUcs4 < 0x0080)) ||
              ((3 == $mBytes) && ($mUcs4 < 0x0800)) ||
              ((4 == $mBytes) && ($mUcs4 < 0x10000)) ||
              (4 < $mBytes) ||
              // From Unicode 3.2, surrogate characters are illegal
              (($mUcs4 & 0xFFFFF800) == 0xD800) ||
              // Codepoints outside the Unicode range are illegal
              ($mUcs4 > 0x10FFFF)) {
            return false;
          }
          if (0xFEFF != $mUcs4) {
            // BOM is legal but we don't want to output it
            $out[] = $mUcs4;
          }
          //initialize UTF8 cache
          $mState = 0;
          $mUcs4  = 0;
          $mBytes = 1;
        }
      } else {
        /* ((0xC0 & (*in) != 0x80) && (mState != 0))
         * 
         * Incomplete multi-octet sequence.
         */
        return false;
      }
    }
  }
  return $out;
}

/* Special remap table for Unicode characters */
$r2 = array(
	0x20ac => 0x80,
	0x201a => 0x82,
	0x0192 => 0x83,
	0x201e => 0x84,
	0x2026 => 0x85,
	0x2020 => 0x86,
	0x2021 => 0x87,
	0x02C6 => 0x88,
	0x2030 => 0x89,
	0x0160 => 0x8A,
	0x2039 => 0x8B,
	0x0152 => 0x8C,
	0x017D => 0x8E,
	0x2018 => 0x91,
	0x2019 => 0x92,
	0x201C => 0x93,
	0x201D => 0x94,
	0x2022 => 0x95,
	0x2013 => 0x96,
	0x2014 => 0x97,
	0x02DC => 0x98,
	0x2122 => 0x99,
	0x0161 => 0x9A,
	0x203A => 0x9B,
	0x0153 => 0x9C,
	0x017E => 0x9E,
	0x0178 => 0x9F,
);

/* Main code starts from here */
/* Read broken image as a binary string to further processing */
$broken = file_get_contents('broken.jpg');

/* Convert utf-8 with non-shortest codes to Unicode codepoints */
$aa = utf8ToUnicode($broken);

/* Going through codepoints, remap special codes and convert bytes to chars */
$restored = '';
foreach ($aa as $d) {
	$ch = $d;
	if (isset($r2[$d])) {
		$ch = $r2[$d];
	}
	if ($ch > 255) {
		echo 'Wrong char! ' . sprintf('%04X', $ch);
	}
	$restored .= chr($ch);
}

/* Put restored image to file */
file_put_contents('restored.jpg', $restored);

/* Happy end */

На сегодня всё. Жду ваших вопросов и комментариев.

Отправить ответ

Оставьте первый комментарий!

Notify of
avatar
wpDiscuz