dimhold.by
← Статьи

Читаем UTF-8 как 1251: "Привет"

Мне надоело гадать. Я решил посмотреть на это в байтах. Напечатал Привет в UTF-8 и получил двенадцать байт на шесть букв. Потом отдал их декодеру, который уверен, что один байт равен одному символу:

$ printf 'Привет' | xxd
00000000: d09f d180 d0b8 d0b2 d0b5 d182            ............

$ printf 'Привет' | iconv -f CP1251 -t UTF-8
Привет
набрано П р и в е т байты utf-8 d0 9f d1 80 d0 b8 d0 b2 d0 b5 d1 82 читаем как CP1251 Р џ С Ђ Р ё Р І Р µ С читаем как CP1252 Ð Ÿ Ñ Ð ¸ Ð ² Ð µ Ñ читаем как ISO-8859-1 Ð Ñ Ð ¸ Ð ² Ð µ Ñ читаем как CP866 Я А В читаем как KOI8-R п ÷ я п п п я
Двенадцать байт не меняются. Меняется только таблица, по которой их читают. В ISO-8859-1 на местах 9f, 80 и 82 стоят управляющие C1, поэтому три ячейки пустуют, а CP1252 их заполняет.

Обычно я чиню такое перебором кодировок в выпадающем списке, пока текст не вернётся. Способ работает. При этом я ни разу не заглянул, что там под капотом.

Шестьдесят шесть букв, два первых байта

Русская кириллица живёт от U+0410 до U+044F, а Ё и ё стоят отдельно на U+0401 и U+0451. В UTF-8 все они занимают по два байта. Я собрал первый байт каждой буквы, прописной и строчной:

$ perl -CO -e 'print map { chr } (0x401, 0x451, 0x410..0x44f)' \
    | perl -ne 'for my $b (unpack "C*", $_) { $seen{$b} = 1 if $b >= 0xC0 }
        END { print join " ", map { sprintf "%02x", $_ } sort keys %seen }'
d0 d1

Два значения на шестьдесят шесть букв. Значит, любая однобайтовая кодовая страница нарисует своё представление о d0 и d1 перед каждым вторым символом. Из-за этого мусор можно читать в обратную сторону:

декодерd0d1Привет приходит как
CP1251РСПривет
CP1252ÐÑПривет
ISO-8859-1ÐÑÐÑивеÑ
CP866╨Я╤А╨╕╨▓╨╡╤В
KOI8-Rпяп÷я─п╦п╡п╣я┌

Частокол из Р и С значит, что файл прочитали как 1251. На практике это почти всегда мой случай. Остальные четыре встречаются реже и точно так же читаются по первой колонке.

CP1252 и ISO-8859-1 согласны насчёт d0 и d1, поэтому отпечаток у них одинаковый. А вот в диапазоне от 80 до 9f они расходятся. У CP1252 там печатные символы. Он выдаёт Привет: двенадцать символов на двенадцать байт. В ISO-8859-1 тот же диапазон занят управляющими C1. Байты 9f, 80 и 82 не печатаются вообще, поэтому та же строка выходит как ÐÑивеÑ, девять видимых символов. Я полчаса сверял количество символов, прежде чем до этого дошёл.

Фокус у́же, чем кажется. Белорусская кириллица тоже укладывается в d0 и d1, вместе с ў. А украинская ломает правило: Ґ кодируется как d2 90. В 1251 d2 рисуется как Т.

Обратно уже не собрать

Прочитать байты 1251 как UTF-8 значит устроить ту же аварию наоборот. Ведёт она себя совсем иначе:

$ printf 'Привет' | iconv -f UTF-8 -t CP1251 | xxd
00000000: cff0 e8e2 e5f2                           ......

$ printf 'Привет' | iconv -f UTF-8 -t CP1251 | iconv -f UTF-8 -t UTF-8
iconv: (stdin):1:0: cannot convert

Ни один из этих шести байт не начинает корректную UTF-8-последовательность, поэтому iconv останавливается на первом же. Редактор, в котором у меня был открыт тот же файл, повёл себя менее честно. Он подставил U+FFFD на каждую позицию, из-за чего кажется, будто с текстом ещё можно работать.

Вариант с Р и С сохраняет все исходные байты. Если прогнать кракозябры обратно через 1251, возвращается ровно то, что было:

$ printf 'Привет' | iconv -f CP1251 -t UTF-8 | iconv -f UTF-8 -t CP1251 | xxd
00000000: d09f d180 d0b8 d0b2 d0b5 d182            ............

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

$ printf 'Привет' | iconv -f CP1251 -t UTF-8 | wc -c
25

В KOI8-R алфавит разложен по латинице

KOI8-R выглядит так, будто алфавит перетасовали. ю идёт раньше а, ц сидит между б и д. Вот верхняя половина таблицы, а рядом с каждым байтом стоит ASCII-символ, в который он превращается после сброса восьмого бита:

c0 ю @  c1 а A  c2 б B  c3 ц C  c4 д D  c5 е E  c6 ф F  c7 г G
c8 х H  c9 и I  ca й J  cb к K  cc л L  cd м M  ce н N  cf о O
d0 п P  d1 я Q  d2 р R  d3 с S  d4 т T  d5 у U  d6 ж V  d7 в W
d8 ь X  d9 ы Y  da з Z  db ш [  dc э \  dd щ ]  de ч ^  df ъ _

а стоит на A, б на B, ц на C, ф на F. Большинство букв легло поверх своей латинской транслитерации. Тем, кому пары не нашлось, достались остатки: так ю оказалась на @, а ъ на _. Сбросьте восьмой бит у строки в KOI8-R. Транслитерация проявится сама:

$ printf 'Кодировка' | iconv -f UTF-8 -t KOI8-R | perl -pe 's/(.)/chr(ord($1) & 0x7f)/ge'
kODIROWKA

Здравствуйте   fa c4 d2 c1 d7 d3 d4 d7 d5 ca d4 c5   ->   zDRAWSTWUJTE
Кодировка      eb cf c4 c9 d2 cf d7 cb c1            ->   kODIROWKA
Спасибо        f3 d0 c1 d3 c9 c2 cf                  ->   sPASIBO
koi8-r К = eb = 1 1 1 0 1 0 1 1 bit 8 & 0x7F k = 6b = 0 1 1 0 1 0 1 1 Кодировка kODIROWKA
Байт eb в KOI8-R это К. Сбросьте восьмой бит, получится 6b, то есть k. Весь алфавит разложен по этому принципу.

Регистр переворачивается, потому что строчная кириллица занимает диапазон прописной латиницы. Канал по дороге съел восьмой бит, а текст всё ещё читается. При таком проценте попаданий это вряд ли совпадение. Та же операция над байтами 1251 даёт Ophber.

Все три слова, которые я взял, состоят из букв с латинской парой. ъ или щ внутри вышли бы знаками препинания.

Что не получилось

Я хотел прогнать через все декодеры фразу подлиннее, Здравствуйте, коллеги. CP1252 отказался:

CP1252   ЗдравÑ
iconv: (stdin):1:11: cannot convert

Он встал на одиннадцатом байте, а это 81. В CP1252 пять байтовых значений не определены. 81 одно из них, так что конвертеру, который идёт строго по таблице, вернуть нечего. Тот самый диапазон C1, из-за которого CP1252 и ISO-8859-1 печатают по-разному, оказался и тем, где один из них падает совсем.

В этом диапазоне тесно. У тридцати четырёх букв из шестидесяти шести второй байт лежит между 80 и 9f. То есть чуть больше половины любого кириллического текста попадает туда, где эти две таблицы перестают совпадать. Что делают с теми же байтами CP1250 и CP1257, я пока не проверял.