11 июля 2012

Определение кодировки текста в PHP и Python

В очередном проекте, понадобилось мне определить кодировку текста, который возвращался в виде XML. Для определения кодировки я уже достаточно давно использовал очень простую функцию, которую сам и написал. Но по воли случая, её не оказалось под рукой. И какой бы функция простой не была, хоть и знал общий принцип её работы, писать её заново мне совершенно не хотелось. Да, обленился я в край, тут уж ничего не поделаешь. И так как ждать мне не хотелось ещё больше, чем переписывать, я, понятное дело, побежал спрашивать Google, чем он мне может помочь...
Чего я только не нашел в его предложениях! Кто-то мистическим образом пытается определить кодировку через preg_match, ограничиваясь кодировкой UTF-8. Кто-то в разных кодировках "парсит" тома "Война и мир" на двубуквенные совпадения, получая на выходе достаточно массивные файлы, при чем для каждой кодировки - собственный. Кто-то даже добрался до исходников mbstring, так как в какой-то кодировке он возвращал не верный результат! Меня хватил шок от увиденного! Я понял одно, что из найденных вариантов, подобную "залипуху" в свой код я пихать не собираюсь, поэтому решение отложил на следующий день. К тому же, мне по проекту это требовалось для отладочной информации: мог спокойно потерпеть и смотреть на возвращающиеся "зюки" и вопросики. Однако, мысли о том, что нужно поделиться с народом как простым способом определить кодировку без подключения сторонних библиотек, меня подтолкнули на написание данной статьи.

Идея - Лотерея

Саму идею получения кодировки придумал не я, но и автора, к сожалению, сообщить сейчас уже не могу, так как это было порядка 4 лет назад и откуда я взял эту информацию - уже давно забылось. Автор предложил вариант определения и показал пример для 1-2 кодировок на языке Python. Простота его решения не оставила меня в стороне, и я развил её до желаемого результата.
Суть идеи заключается в самих кодовых таблицах кодировок. Как известно, любая кодировка содержит свою кодовую таблицу и для каждого символа кодировки присвоено определенное значение. Таблицы кодировок я здесь показывать не буду, сейчас их найти в интернете достаточно просто.
Принцип реализации следующий:

  1. Создается переменная-массив для хранения результата "анализа" проверенного текста. Каждый элемент массива будет содержать результат для конкретной кодировки.
  2. Полученный на вход функции текст перебирается по символьно.
  3. От каждого символа берется ординал (значение этого символа) и сравнивается с диапазоном кодировки.
  4. Если значение выпадает на прописной (заглавный) символ, элементу массива, который хранит результат этой кодировки, прибавляется значение 1.
  5. Если значение выпадает на строчный (маленький) символ, элементу массива, который хранит результат этой кодировки, прибавляется значение 3.
  6. Та кодировка, точнее, тот элемент массива, который хранит результат о своей кодировке, который набрал больше всего баллов - вероятней всего и является исходной кодировкой.

Такой алгоритм справедлив для однобайтовых кодировок, таких как KOI-8, CP1251 (windows-1251) и других. Однако, для двухбайтовых кодировок (в моем случае UTF-8), такой подход выдаст ошибочный результат. Для начала я попытался решить этот вопрос путем прибавления для прописных символов - 5, для строчных - 7. Результат стал лучше, однако все равно ошибки распознавания присутствовали. После недолгих экспериментов, я вывел, что для верного определения UTF, при прописных символах должно прибавляться к результату 10, для строчных 14, то есть в 2 раза больше начального моего предположения. Тем не менее, для лучшего визуального понимания кода я для символов UTF оставил 5 и 7 соответственно и уже во время проверки эти значения увеличивал на 2 и прибавлял к результату.
Вот в принципе и весь алгоритм. И без всяких лишних заморочек.
Больше всего времени на реализацию этой функции у меня убилось конечно же на поиск кодовых таблиц и правильной расстановки диапазонов. Мало того, что на тот момент, когда я первый раз писал эту функцию, найти актуальную кодовую таблицу было достаточно трудно, так ещё диапазоны символов в них скачут как попало. Тем не менее, я тогда остановился на самых актуальных (и по сей день) кодировках: UTF-8, CP1251, KOI8-R, IBM866, ISO-8859-5 и MAC. Если вам недостаточно этих кодировок, вы можете на основе данного алгоритма дополнить код.

От слов к практике

Собственно, весь код функции на Python выглядит следующим образом:

encodings = {
    'UTF-8':      'utf-8',
    'CP1251':     'windows-1251',
    'KOI8-R':     'koi8-r',
    'IBM866':     'ibm866',
    'ISO-8859-5': 'iso-8859-5',
    'MAC':        'mac',
}

"""
Определение кодировки текста
"""
def get_codepage(str = None):
    uppercase = 1
    lowercase = 3
    utfupper = 5
    utflower = 7
    codepages = {}
    for enc in encodings.keys():
        codepages[enc] = 0
    if str is not None and len(str) > 0:
        last_simb = 0
        for simb in str:
            simb_ord = ord(simb)

            """non-russian characters"""
            if simb_ord < 128 or simb_ord > 256:
                continue

            """UTF-8"""
            if last_simb == 208 and (143 < simb_ord < 176 or simb_ord == 129):
                codepages['UTF-8'] += (utfupper * 2)
            if (last_simb == 208 and (simb_ord == 145 or 175 < simb_ord < 192)) \
                or (last_simb == 209 and (127 < simb_ord < 144)):
                codepages['UTF-8'] += (utflower * 2)

            """CP1251"""
            if 223 < simb_ord < 256 or simb_ord == 184:
                codepages['CP1251'] += lowercase
            if 191 < simb_ord < 224 or simb_ord == 168:
                codepages['CP1251'] += uppercase

            """KOI8-R"""
            if 191 < simb_ord < 224 or simb_ord == 163:
                codepages['KOI8-R'] += lowercase
            if 222 < simb_ord < 256 or simb_ord == 179:
                codepages['KOI8-R'] += uppercase

            """IBM866"""
            if 159 < simb_ord < 176 or 223 < simb_ord < 241:
                codepages['IBM866'] += lowercase
            if 127 < simb_ord < 160 or simb_ord == 241:
                codepages['IBM866'] += uppercase

            """ISO-8859-5"""
            if 207 < simb_ord < 240 or simb_ord == 161:
                codepages['ISO-8859-5'] += lowercase
            if 175 < simb_ord < 208 or simb_ord == 241:
                codepages['ISO-8859-5'] += uppercase

            """MAC"""
            if 221 < simb_ord < 255:
                codepages['MAC'] += lowercase
            if 127 < simb_ord < 160:
                codepages['MAC'] += uppercase

            last_simb = simb_ord

        idx = ''
        max = 0
        for item in codepages:
            if codepages[item] > max:
                max = codepages[item]
                idx = item
        return idx

Пример вызова функции

print encodings[get_codepage(file("test.txt").read())]

А что же на счет PHP

Переписать уже готовую функцию из Python в PHP не составило никакого труда. По своему виду он практически ничем не отличается от его прородителя на Python:

/**
 * Определение кодировки текста
 * @param String $text Текст
 * @return String Кодировка текста
 */
function get_codepage($text = '') {
    if (!empty($text)) {
        $utflower  = 7;
        $utfupper  = 5;
        $lowercase = 3;
        $uppercase = 1;
        $last_simb = 0;
        $charsets = array(
            'UTF-8'       => 0,
            'CP1251'      => 0,
            'KOI8-R'      => 0,
            'IBM866'      => 0,
            'ISO-8859-5'  => 0,
            'MAC'         => 0,
        );
        for ($a = 0; $a < strlen($text); $a++) {
            $char = ord($text[$a]);

            // non-russian characters
            if ($char<128 || $char>256)
                continue;

            // UTF-8
            if (($last_simb==208) && (($char>143 && $char<176) || $char==129))
                $charsets['UTF-8'] += ($utfupper * 2);
            if ((($last_simb==208) && (($char>175 && $char<192) || $char==145))
                || ($last_simb==209 && $char>127 && $char<144))
                $charsets['UTF-8'] += ($utflower * 2);

            // CP1251
            if (($char>223 && $char<256) || $char==184)
                $charsets['CP1251'] += $lowercase;
            if (($char>191 && $char<224) || $char==168)
                $charsets['CP1251'] += $uppercase;

            // KOI8-R
            if (($char>191 && $char<224) || $char==163)
                $charsets['KOI8-R'] += $lowercase;
            if (($char>222 && $char<256) || $char==179)
                $charsets['KOI8-R'] += $uppercase;

            // IBM866
            if (($char>159 && $char<176) || ($char>223 && $char<241))
                $charsets['IBM866'] += $lowercase;
            if (($char>127 && $char<160) || $char==241)
                $charsets['IBM866'] += $uppercase;

            // ISO-8859-5
            if (($char>207 && $char<240) || $char==161)
                $charsets['ISO-8859-5'] += $lowercase;
            if (($char>175 && $char<208) || $char==241)
                $charsets['ISO-8859-5'] += $uppercase;

            // MAC
            if ($char>221 && $char<255)
                $charsets['MAC'] += $lowercase;
            if ($char>127 && $char<160)
                $charsets['MAC'] += $uppercase;

            $last_simb = $char;
        }
        arsort($charsets);
        return key($charsets);
    }
}

Пример вызова функции

echo get_codepage(file_get_contents("test.txt"));


ЛикБез, или Не мешайте машине работать

Не стоит пытаться устраивать crash-test для этой функции. Из алгоритма понятно, что чем меньше текста ей придет на вход, тем больше вероятности, что функция распознает кодировку не верно. С другой стороны, скармливать тома Льва Толстого тоже не имеет смысла: данный метод прекрасно справляется с небольшим предложением в 100-200 символов. И хоть я в примерах вызова на вход отправлял все содержимое некоего файла "test.txt", в котором предполагалось, что находится текст, кодировку которого нужно определить, на вход функции можно (и нужно) передавать небольшой участок текста.
Извращения с перемешанными прописными и строчными буквами вообще считаю не уместными в данном случае, так как этот метод писался для обычной заурядной задачи с приближенно грамотным русским языком. А такие эксперименты мне чаще всего напоминают анекдот:

Русский древообрабатывающий завод приобрел японский агрегат. Собрались русские рабочие вокруг него и давай разбираться, как он работает.
Один взял доску, сунул в нее.
Агрегат: дзззззззззззззззынь...
На выходе готовая табуретка.
Мужики: Ни чего себе!!!
Агрегат на дисплее: ну а как вы думали?
Другой взял не обтесанное бревно, вставил в агрегат.
Агрегат: дзззззззззззззззынь...
На выходе готовый резной сервант.
Мужики: Ни чего себе!!!
Агрегат на дисплее: ну а как вы думали?
Третий мужик не выдержал, откуда то притянул рельсу и всунул в агрегат.
Агрегат: дрррррр-тых-тых-тых...
Задымился и на дисплее надпись: Ни чего себе!!!
Мужики: ну а как ты думала!!!

Так что для подобных извращенных тестов вам скорее всего понадобится извращенный алгоритм, каким данная функция не является. А из практики скажу, что за время использования в течении 4 лет, данный метод меня ни разу не подвел и всегда давал верный результат.
Надеюсь моя статья станет кому то полезной.
Спасибо за внимание.

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


9 комментариев :

  1. Спасибо за отличную функцию, очень помогли!!!

    ОтветитьУдалить
  2. Неправильно определяет 1251

    ОтветитьУдалить
    Ответы
    1. Всё определяется правильно, проверено временем. Покажите свой пример, в котором у вас не правильно кодировка определилась.

      Удалить
  3. У меня для маленького файла всё ок. А для txt около 2.5 Гб не работает.

    ОтветитьУдалить
    Ответы
    1. Tonyk, зачем же так издеваться над машиной, на которой производится данная операция? 2,5Гб текста по данной функции - это 2,5 миллиарда итераций!!! Ужос... Что мешает сделать так?

      Удалить
    2. $source_file = '/path/to/big_file_3gb.txt';
      $symbols_count = 200;
      if (($hndl = fopen($source_file, 'r') !== FALSE) {
      if (($part = fgets($hndl, $symbols_count)) !== FALSE) {
      echo 'Codepage: '.get_codepage($part);
      } else {
      echo 'Cannot read file';
      }
      fclose($hndl);
      } else {
      echo 'Cannot open file';
      }

      Удалить
  4. Анонимный22 мая, 2013 03:42

    спасибо, ты лучший!!

    ОтветитьУдалить
    Ответы
    1. Спасибо, я польщен, но... я не лучший, я стараюсь максимально к этому приблизиться =)

      Удалить