[Из песочницы] Обратимая транслитерация кириллицы

Возможно, кто-то еще помнит, как писали SMS, а иногда и письма, «транслитом». Но зачем транслитерация сегодня, когда везде уже unicode? К сожалению, унаследованные приложения выходят из эксплуатации намного медленнее, чем хотелось бы. Например, и сегодня используются томографы, не допускающие кириллицу в именах пациентов. При том, что информационная система, используемая тем же отделением, прекрасно кириллицу понимает. И оператору томографа нужно не просто позвать пациента на исследование, но и правильно записать его фамилию в какие-нибудь документы. Похожие ситуации могут встретится в разных местах.

То есть, возникает задача как-то передать текстовые данные в унаследованную систему, чтобы:

  • человек — оператор унаследованной системы смог прочесть полученный текст «по звучанию»
  • при необходимости можно было бы однозначно восстановить исходный кириллический текст


Чтобы не было скучно, добавим более подробных требований, связанных с совместимостью и простотой для человека:

  1. использовать только буквы в узком смысле, без знаков препинания и диакритических элементов (это заодно позволит сохранить регистр)
  2. каждую исходную букву преобразовывать независимо от остальных (без сложностей вроде «в начале / в конце слова» и т.п.)
  3. замены как можно более короткие, в идеале одно-буквенные
  4. правила обратного преобразованния как можно проще, например, замены должны соответствовать условию Фано
  5. близкие по звучанию замены, в представлении «обычного человека» — на практике это некая смесь из латыни, английской, французской, немецкой и, иногда, испанской фонетики


Конечно, перечисленное не совсем требования (кроме первых двух), а, скорее, эвристики.

Можно найти много готовых вариантов транслитерации кириллицы в латиницу. Но среди них не нашлось ничего, что бы удовлетворяло всем требованиям в приемлемой степени. То использует диакритические символы, как стандарты, то выбрасывает буквы (обычно «Ъ»), то предлагают необратимые (щ —> shch) или фонетически дикие (ш —> w) варианты замены, или имеют другие фатальные недостатки.

Значит, делаем свой велосипед. Собственно, нужно составить таблицу соответствия, и описать алгоритм преобразования туда и обратно.

Таблица


Начнем со всем очевидных одно-буквенных замен:

А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я
A B V G D E Z I K L M N O P R S T U F


Помня о требовании возможно коротких замен, и поскольку для «С» используем «S», с чистой совестью используем для «Ц» символ «C».

Для оставшихся букв традиция (и просто нехватка символов латиницы) велит нам использовать двух-буквенные сочетания. Для простоты прямого и, в особенности, обратного преобразования, хорошо бы, чтобы сочетания образовывали символы, не используемые вне сочетаний. По условию Фано, такой особый символ должен стоять в начале сочетания, но традиция слишком сильна и букву «H» будем писать все-таки в конце сочетания. Но, если отдельно символ «H» не использовать и разрешить алгоритму преобразования «возвращаться» (на самом деле, — запоминать) к предыдущему входному символу, для постфиксных сочетаний можно считать выполненным «перевернутый» аналог условия Фано. То есть алгоритм сможет их однозначно определять.

Особый символ для согласных везде один — «H». А для гласных есть два варианта: «Y» и «J». Хотя «Y» привычнее, он также часто используется отдельно, для «Й» или для «Ы». А «J» скорее воспринимается как чисто вспомогательный символ.

Решено, используем для гласных «J». А кстати освободившийся «Y» используем для «Й».

Раз «J» теперь особый символ, использовать его для «Ж» нельзя, и остается только «ZH». Аналогично, для «Х» нельзя использовать «H», и остается только «KH».

Теперь можем записать общепринятые и выбранные сочетания и одиночные символы:

А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я
A B V G D E ZH Z I Y K L M N O P R S T U F KH C CH SH EH JU JA


Распространенные и хорошие (в смысле наших требований) замены здесь кончились, и мы вступаем на зыбкую почву «отсебятины», аналогий и компромиссов.

Начнем с «Ы». «Y» уже занят (помним про обратимость), да и фонетически это плохая замена. Посмотрим на решение для «Э» (взято, между прочим, из ISO/R 9, 1968 г.). По аналогии «Ы» должно заменятся на «IH». Странно, что такой вариант нигде не встретился.

С «Ё» ситуация тоже странная. Есть понятный, но не подходящий нам вариант «E». И есть фонетический вариант «JO». Но в русском алфавите «Ё» не случайно сделана на основе «Е», а не «О». «Ё» часто чередуется с «Е», например «клён — кленовый», и никогда не чередуется с «О». Это получается еще одна эвристика — «алфавитная» (не фонетическая и не графическая) близость букв. В результате для «Ё» конструируем замену «JE».
Сделаем паузу:

А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я
A B V G D E JE ZH Z I Y K L M N O P R S T U F KH C CH SH IH EH JU JA


Как хорошо было бы на этом остановится и сказать, что задача в первом приближении решена. Но остались еще три буквы, без которых никак не обойтись. Для оставшихся букв нет никаких адекватных вариантов. Знаки обычно заменяют апострофами, а буквенные замены либо просто произвольны, либо «остроумны», вроде «ь» —>«q». Для «Щ» замена без диакритических знаков обычно длиной в 3 — 4 символа, и с ней еще будут проблемы.

После долгих исканий и страданий, пришлось остановится на таком рассуждении: для букв, которым не соответствуют звуки, нельзя использовать буквы, для которых звуки есть. И нам остаются только «специальные» символы для образования сочетаний. Но по условию Фано их нельзя использовать отдельно, сочетания станут неоднозначны.

Выход — использовать сочетания специальных символов друг с другом. Это, видимо, еще немного усложнит алгоритм преобразования, но кажется возможным сохранить однозначность.

Для твердого знака (он у нас только разделительный) интуитивно кажется уместной замена «HH» (не читается, как пауза, разделение).

А для мягкого знака цепочки ассоциаций («J» —> йотированные гласные —> смягчение предыдущей согласной) + («H» —> разделение) приводят к замене «JH».

Не назовешь красивым решением, но среди гнилых яблок выбор не велик.

К сожалению, такой выбор делает невозможным использовать замену «Щ» —> «SHH». Последовательность «SHH» будет означать «СЪ», и такое сочетание встречается в русском языке (например, «съезд»). Тут снова нет симпатичных решений, и надо искать хоть как-то мотивированные. Звук «Щ» близок к смягченному «Ш», и по аналогии с мягким знаком можно это изобразить префиксным «J». Понимаю, что сейчас ссылаюсь сам на себя, что код все равно длинны 3 и не стандартный. Но, как говорится, «других писателей у нас для вас нЭт».

В результате:

А Б В Г Д Е Ё Ж З И Й К Л М Н О П Р С Т У Ф Х Ц Ч Ш Щ Ъ Ы Ь Э Ю Я
A B V G D E JE ZH Z I Y K L M N O P R S T U F KH C CH SH JSH HH IH JH EH JU JA


Алгоритм


Преобразование из кириллицы в латиницу тривиально. На регистр не обращаем внимания для краткости.

Код на Java
public class Translit {
        public static String cyr2lat(char ch){
                switch (ch){
                        case 'А': return "A";
                        case 'Б': return "B";
                        case 'В': return "V";
                        case 'Г': return "G";
                        case 'Д': return "D";
                        case 'Е': return "E";
                        case 'Ё': return "JE";
                        case 'Ж': return "ZH";
                        case 'З': return "Z";
                        case 'И': return "I";
                        case 'Й': return "Y";
                        case 'К': return "K";
                        case 'Л': return "L";
                        case 'М': return "M";
                        case 'Н': return "N";
                        case 'О': return "O";
                        case 'П': return "P";
                        case 'Р': return "R";
                        case 'С': return "S";
                        case 'Т': return "T";
                        case 'У': return "U";
                        case 'Ф': return "F";
                        case 'Х': return "KH";
                        case 'Ц': return "C";
                        case 'Ч': return "CH";
                        case 'Ш': return "SH";
                        case 'Щ': return "JSH";
                        case 'Ъ': return "HH";
                        case 'Ы': return "IH";
                        case 'Ь': return "JH";
                        case 'Э': return "EH";
                        case 'Ю': return "JU";
                        case 'Я': return "JA";
                        default: return String.valueOf(ch);
                }
        }

        public static String cyr2lat(String s){
                StringBuilder sb = new StringBuilder(s.length()*2);
                for(char ch: s.toCharArray()){
                        sb.append(cyr2lat(ch));
                }
                return sb.toString();
        }
}


Для примера результата пара известных панграмм:

SHirokaja ehlektrifikacija juzhnihkh guberniy dast mojshnihy tolchok podhhjemu seljhskogo khozjaystva.
Shheshjh zhe ejshje ehtikh mjagkikh francuzskikh bulok da vihpey chaju.


Выглядит не очень, но основное назначение этого варианта транслитерации все-таки ФИО:

Aleksandr Ivanovich Lebedjh
Georgiy Konstantinovich ZHukov


С обратным преобразованием куда интереснее. Особенно учитывая, что его хорошо бы объяснить человеку (не из IT) для выполнения «в уме».
Видимо, начать надо с особых случаев.

  • Поскольку читаем мы слева направо, первым дело обращаем внимание на символ «J». За ним обязательно должен идти один из пяти символов: «E», «H», «U», «A» или «S» (за «S» должен в этом случае обязательно быть еще «H»), и получается то, что в таблице для двух-трех буквенных сочетаний.
  • Если «J» нет, смотрим, не идет ли следом за символом буква «H». Тут самый тяжелый для внимания момент: в этот случай не должен попасть вариант, когда третьим символом снова идет «H» (это код «HH»). То есть видеть и анализировать надо три символа подряд. Вот где нарушение условия Фано аукнулось (хорошо, что один раз).
  • Если ни «J», ни одиночного «H» поблизости от символа не обнаружилось, смело заменяем его по таблице как отдельную букву.


После недолгой тренировки, как показывает практика, люди способны выполнять обратное преобразования вручную. Но заставлять их это делать без особой необходимости, конечно, не надо. Можно и автоматизировать (опять же для простоты только для строки и в верхнем регистре):

Код на Java
 public static String lat2cyr(String s){
                StringBuilder sb = new StringBuilder(s.length());
                int i = 0;
                while(i < s.length()){// Идем по строке слева направо. В принципе, подходит для обработки потока
                        char ch = s.charAt(i);
                        if(ch == 'J'){ // Префиксная нотация вначале
                                i++; // преходим ко второму символу сочетания
                                ch = s.charAt(i);
                                switch (ch){
                                        case 'E': sb.append( 'Ё'); break;
                                        case 'S':
                                                sb.append( 'Щ');
                                                i++; // преходим к третьему символу сочетания
                                                if(s.charAt(i) != 'H') throw new IllegalArgumentException("Illegal transliterated symbol at position "+i);// вариант третьего символа только один
                                                break;
                                        case 'H': sb.append( 'Ь'); break;
                                        case 'U': sb.append( 'Ю'); break;
                                        case 'A': sb.append( 'Я'); break;
                                        default: throw new IllegalArgumentException("Illegal transliterated symbol at position "+i);
                                }
                        }else if(i+1 < s.length() && s.charAt(i+1)=='H' && !(i+2 < s.length() && s.charAt(i+2)=='H')){// Постфиксная нотация, требует информации о двух следующих символах. Для потока придется сделать обертку с очередью из трех символов.
                                switch (ch){
                                        case 'Z': sb.append( 'Ж'); break;
                                        case 'K': sb.append( 'Х'); break;
                                        case 'C': sb.append( 'Ч'); break;
                                        case 'S': sb.append( 'Ш'); break;
                                        case 'E': sb.append( 'Э'); break;
                                        case 'H': sb.append( 'Ъ'); break;
                                        case 'I': sb.append( 'Ы'); break;
                                        default: throw new IllegalArgumentException("Illegal transliterated symbol at position "+i);
                                }
                                i++; // пропускаем постфикс
                        }else{// одиночные символы
                                switch (ch){
                                        case 'A': sb.append( 'А'); break;
                                        case 'B': sb.append( 'Б'); break;
                                        case 'V': sb.append( 'В'); break;
                                        case 'G': sb.append( 'Г'); break;
                                        case 'D': sb.append( 'Д'); break;
                                        case 'E': sb.append( 'Е'); break;
                                        case 'Z': sb.append( 'З'); break;
                                        case 'I': sb.append( 'И'); break;
                                        case 'Y': sb.append( 'Й'); break;
                                        case 'K': sb.append( 'К'); break;
                                        case 'L': sb.append( 'Л'); break;
                                        case 'M': sb.append( 'М'); break;
                                        case 'N': sb.append( 'Н'); break;
                                        case 'O': sb.append( 'О'); break;
                                        case 'P': sb.append( 'П'); break;
                                        case 'R': sb.append( 'Р'); break;
                                        case 'S': sb.append( 'С'); break;
                                        case 'T': sb.append( 'Т'); break;
                                        case 'U': sb.append( 'У'); break;
                                        case 'F': sb.append( 'Ф'); break;
                                        case 'C': sb.append( 'Ц'); break;
                                        default: sb.append(ch);
                                }
                        }

                        i++; // переходим к следующему символу
                }
                return sb.toString();
        }


Итог


Казалось бы, простая и давно решенная задача, а какой простор для творчества и обсуждений.

Если серьезно, получился рабочий алгоритм обратимой транслитерации всех букв русской кириллицы в буквы латиницы. При этом результат, со скидкой на жесткость требований, приемлемо читаем. Можно использовать для интеграции с унаследованными системами и библиотеками, для генерации идентификаторов.

Надеюсь, кому-нибудь решение покажется полезным, а путь к нему — занятным.

© Habrahabr.ru