Мне надоело гадать. Я решил посмотреть на это в байтах. Напечатал Привет в UTF-8 и получил двенадцать байт на шесть букв. Потом отдал их декодеру, который уверен, что один байт равен одному символу:
$ printf 'Привет' | xxd
00000000: d09f d180 d0b8 d0b2 d0b5 d182 ............
$ printf 'Привет' | iconv -f CP1251 -t UTF-8
Привет
Обычно я чиню такое перебором кодировок в выпадающем списке, пока текст не вернётся. Способ работает. При этом я ни разу не заглянул, что там под капотом.
Шестьдесят шесть букв, два первых байта
Русская кириллица живёт от 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 перед каждым вторым символом. Из-за этого мусор можно читать в обратную сторону:
| декодер | d0 | d1 | Привет приходит как |
|---|---|---|---|
| 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
Регистр переворачивается, потому что строчная кириллица занимает диапазон прописной латиницы. Канал по дороге съел восьмой бит, а текст всё ещё читается. При таком проценте попаданий это вряд ли совпадение. Та же операция над байтами 1251 даёт Ophber.
Все три слова, которые я взял, состоят из букв с латинской парой. ъ или щ внутри вышли бы знаками препинания.
Что не получилось
Я хотел прогнать через все декодеры фразу подлиннее, Здравствуйте, коллеги. CP1252 отказался:
CP1252 ЗдравÑ
iconv: (stdin):1:11: cannot convert
Он встал на одиннадцатом байте, а это 81. В CP1252 пять байтовых значений не определены. 81 одно из них, так что конвертеру, который идёт строго по таблице, вернуть нечего. Тот самый диапазон C1, из-за которого CP1252 и ISO-8859-1 печатают по-разному, оказался и тем, где один из них падает совсем.
В этом диапазоне тесно. У тридцати четырёх букв из шестидесяти шести второй байт лежит между 80 и 9f. То есть чуть больше половины любого кириллического текста попадает туда, где эти две таблицы перестают совпадать. Что делают с теми же байтами CP1250 и CP1257, я пока не проверял.