pub fn transliterate_kmu55(text: &str) -> String {
let mut output = String::with_capacity(text.len() * 2);
let mut index = 0usize;
while index < text.len() {
let slice = &text[index..];
let mut ahead = slice.chars();
if let (Some(first), Some(second)) = (ahead.next(), ahead.next()) {
if matches!(first, 'З' | 'з') && matches!(second, 'Г' | 'г') {
let case = letter_case(text, index, first);
push_cased(&mut output, "zgh", case);
index += first.len_utf8() + second.len_utf8();
continue;
}
}
let ch = match slice.chars().next() {
Some(ch) => ch,
None => break,
};
let ch_len = ch.len_utf8();
let case = letter_case(text, index, ch);
let mapped = match ch {
'А' | 'а' => Some("a"),
'Б' | 'б' => Some("b"),
'В' | 'в' => Some("v"),
'Г' | 'г' => Some("h"),
'Ґ' | 'ґ' => Some("g"),
'Д' | 'д' => Some("d"),
'Е' | 'е' => Some("e"),
'Є' | 'є' => {
let base = if is_word_start(text, index) {
"ye"
} else {
"ie"
};
Some(base)
}
'Ж' | 'ж' => Some("zh"),
'З' | 'з' => Some("z"),
'И' | 'и' => Some("y"),
'І' | 'і' => Some("i"),
'Ї' | 'ї' => {
let base = if is_word_start(text, index) {
"yi"
} else {
"i"
};
Some(base)
}
'Й' | 'й' => {
let base = if is_word_start(text, index) { "y" } else { "i" };
Some(base)
}
'К' | 'к' => Some("k"),
'Л' | 'л' => Some("l"),
'М' | 'м' => Some("m"),
'Н' | 'н' => Some("n"),
'О' | 'о' => Some("o"),
'П' | 'п' => Some("p"),
'Р' | 'р' => Some("r"),
'С' | 'с' => Some("s"),
'Т' | 'т' => Some("t"),
'У' | 'у' => Some("u"),
'Ф' | 'ф' => Some("f"),
'Х' | 'х' => Some("kh"),
'Ц' | 'ц' => Some("ts"),
'Ч' | 'ч' => Some("ch"),
'Ш' | 'ш' => Some("sh"),
'Щ' | 'щ' => Some("shch"),
'Ю' | 'ю' => {
let base = if is_word_start(text, index) {
"yu"
} else {
"iu"
};
Some(base)
}
'Я' | 'я' => {
let base = if is_word_start(text, index) {
"ya"
} else {
"ia"
};
Some(base)
}
'Ь' | 'ь' => Some(""),
'\'' | '’' => Some(""),
_ => None,
};
if let Some(base) = mapped {
push_cased(&mut output, base, case);
} else {
output.push(ch);
}
index += ch_len;
}
output
}
#[derive(Copy, Clone)]
enum LetterCase {
Lower,
Capitalized,
Upper,
}
fn letter_case(text: &str, idx: usize, ch: char) -> LetterCase {
if !ch.is_uppercase() {
return LetterCase::Lower;
}
let in_upper_word = next_letter_is_uppercase(text, idx + ch.len_utf8())
|| prev_letter_is_uppercase(text, idx);
if in_upper_word {
LetterCase::Upper
} else {
LetterCase::Capitalized
}
}
fn prev_letter_is_uppercase(text: &str, idx: usize) -> bool {
for prev in text[..idx].chars().rev() {
if prev == '\'' || prev == '\u{2019}' {
continue;
}
if prev.is_alphabetic() {
return prev.is_uppercase();
}
return false;
}
false
}
fn push_cased(output: &mut String, base: &str, case: LetterCase) {
match case {
LetterCase::Lower => output.push_str(base),
LetterCase::Capitalized => {
let mut chars = base.chars();
if let Some(first) = chars.next() {
for upper in first.to_uppercase() {
output.push(upper);
}
}
for rest in chars {
for lower in rest.to_lowercase() {
output.push(lower);
}
}
}
LetterCase::Upper => {
for ch in base.chars() {
for upper in ch.to_uppercase() {
output.push(upper);
}
}
}
}
}
fn is_word_start(text: &str, idx: usize) -> bool {
if idx == 0 {
return true;
}
let mut iter = text[..idx].chars().rev();
while let Some(prev) = iter.next() {
match prev {
'\'' | '’' => continue,
c if is_word_separator(c) => return true,
c if c.is_alphabetic() => return false,
_ => return true,
}
}
true
}
fn is_word_separator(ch: char) -> bool {
ch.is_whitespace()
|| matches!(
ch,
'-' | '–'
| '—'
| '.'
| ','
| ':'
| ';'
| '!'
| '?'
| '('
| ')'
| '['
| ']'
| '{'
| '}'
| '"'
| '«'
| '»'
| '/'
| '\\'
)
}
fn next_letter_is_uppercase(text: &str, mut idx: usize) -> bool {
while idx < text.len() {
let next = match text[idx..].chars().next() {
Some(ch) => ch,
None => break,
};
if next == '\'' || next == '’' {
idx += next.len_utf8();
continue;
}
if next.is_alphabetic() {
return next.is_uppercase();
}
if is_word_separator(next) {
return false;
}
idx += next.len_utf8();
}
false
}
#[cfg(test)]
mod tests {
use super::transliterate_kmu55 as t;
const OFFICIAL: &[(&str, &str)] = &[
("Алушта", "Alushta"),
("Андрій", "Andrii"),
("Борщагівка", "Borshchahivka"),
("Борисенко", "Borysenko"),
("Вінниця", "Vinnytsia"),
("Володимир", "Volodymyr"),
("Гадяч", "Hadiach"),
("Богдан", "Bohdan"),
("Згурівка", "Zghurivka"),
("Ґалаґан", "Galagan"),
("Ґорґани", "Gorgany"),
("Донецьк", "Donetsk"),
("Дмитро", "Dmytro"),
("Рівне", "Rivne"),
("Олег", "Oleh"),
("Есмань", "Esman"),
("Єнакієве", "Yenakiieve"),
("Гаєвич", "Haievych"),
("Короп\'є", "Koropie"),
("Житомир", "Zhytomyr"),
("Жанна", "Zhanna"),
("Жежелів", "Zhezheliv"),
("Закарпаття", "Zakarpattia"),
("Казимирчук", "Kazymyrchuk"),
("Медвин", "Medvyn"),
("Михайленко", "Mykhailenko"),
("Іванків", "Ivankiv"),
("Іващенко", "Ivashchenko"),
("Їжакевич", "Yizhakevych"),
("Кадиївка", "Kadyivka"),
("Мар\'їно", "Marino"),
("Йосипівка", "Yosypivka"),
("Стрий", "Stryi"),
("Олексій", "Oleksii"),
("Київ", "Kyiv"),
("Коваленко", "Kovalenko"),
("Лебедин", "Lebedyn"),
("Леонід", "Leonid"),
("Миколаїв", "Mykolaiv"),
("Маринич", "Marynych"),
("Ніжин", "Nizhyn"),
("Наталія", "Nataliia"),
("Одеса", "Odesa"),
("Онищенко", "Onyshchenko"),
("Полтава", "Poltava"),
("Петро", "Petro"),
("Решетилівка", "Reshetylivka"),
("Рибчинський", "Rybchynskyi"),
("Суми", "Sumy"),
("Соломія", "Solomiia"),
("Тернопіль", "Ternopil"),
("Троць", "Trots"),
("Ужгород", "Uzhhorod"),
("Уляна", "Uliana"),
("Фастів", "Fastiv"),
("Філіпчук", "Filipchuk"),
("Харків", "Kharkiv"),
("Христина", "Khrystyna"),
("Біла Церква", "Bila Tserkva"),
("Стеценко", "Stetsenko"),
("Чернівці", "Chernivtsi"),
("Шевченко", "Shevchenko"),
("Шостка", "Shostka"),
("Кишеньки", "Kyshenky"),
("Щербухи", "Shcherbukhy"),
("Гоща", "Hoshcha"),
("Гаращенко", "Harashchenko"),
("Юрій", "Yurii"),
("Корюківка", "Koriukivka"),
("Яготин", "Yahotyn"),
("Ярошенко", "Yaroshenko"),
("Костянтин", "Kostiantyn"),
("Знам\'янка", "Znamianka"),
("Феодосія", "Feodosiia"),
];
#[test]
fn matches_every_official_example() {
for (cyrillic, expected) in OFFICIAL {
assert_eq!(&t(cyrillic), expected, "{cyrillic}");
}
}
#[test]
fn zgh_applies_wherever_the_pair_occurs() {
assert_eq!(t("Згорани"), "Zghorany");
assert_eq!(t("Розгон"), "Rozghon");
assert_eq!(t("розгін"), "rozghin");
assert_eq!(t("ЗГОРАНИ"), "ZGHORANY");
assert_eq!(t("РОЗГОН"), "ROZGHON");
}
#[test]
fn zgh_never_collides_with_zh() {
for (with_zgh, with_zh) in [
("Розгон", "Рожон"),
("розгін", "рожін"),
("Мозговий", "Можовий"),
] {
assert_ne!(
t(with_zgh),
t(with_zh),
"{with_zgh} and {with_zh} must not transliterate alike"
);
}
}
#[test]
fn all_caps_words_stay_all_caps_to_the_last_letter() {
for (cyrillic, expected) in [
("КЕРЧ", "KERCH"),
("МУЖ", "MUZH"),
("ВЕРХ", "VERKH"),
("БОРЩ", "BORSHCH"),
("ПАЛАЦ", "PALATS"),
("МАРШ", "MARSH"),
("ЗМІЯ", "ZMIIA"),
("ЇЖАКЕВИЧ", "YIZHAKEVYCH"),
("ЖИТОМИР", "ZHYTOMYR"),
("ХАРКІВ", "KHARKIV"),
] {
assert_eq!(&t(cyrillic), expected, "{cyrillic}");
}
}
#[test]
fn a_lone_capital_takes_title_case() {
for (cyrillic, expected) in [("Я", "Ya"), ("Ю", "Yu"), ("Ї", "Yi"), ("Є", "Ye"), ("Ж", "Zh")] {
assert_eq!(&t(cyrillic), expected, "{cyrillic}");
}
}
#[test]
fn soft_sign_and_apostrophe_are_dropped() {
assert_eq!(t("Донецьк"), "Donetsk");
assert_eq!(t("Знам\'янка"), "Znamianka");
assert_eq!(t("Знам\u{2019}янка"), "Znamianka");
assert_eq!(t("Приазов\'я"), "Pryazovia");
}
#[test]
fn text_that_is_not_ukrainian_passes_through() {
assert_eq!(t("RF Лабораторія"), "RF Laboratoriia");
assert_eq!(t("ЕМІ 2026"), "EMI 2026");
assert_eq!(t(""), "");
}
}