fn is_mark(c: char) -> bool {
('\u{05B0}'..='\u{05BC}').contains(&c) || c == '\u{05C1}' || c == '\u{05C2}' || c == '\u{05C7}'
}
fn is_base(c: char) -> bool {
('\u{05D0}'..='\u{05EA}').contains(&c)
}
fn vowel(c: char) -> &'static str {
match c {
'\u{05B0}' | '\u{05B1}' | '\u{05B5}' | '\u{05B6}' => "e", '\u{05B2}' | '\u{05B7}' | '\u{05B8}' => "a", '\u{05B4}' => "i", '\u{05B3}' | '\u{05B9}' | '\u{05C7}' => "o", '\u{05BB}' => "u", _ => "",
}
}
pub fn vowel_vocalisation(c: char) -> &'static str {
match c {
'\u{05B7}' => "a", '\u{05B8}' => "ah", '\u{05B6}' => "e", '\u{05B5}' => "ey", '\u{05B4}' => "i", '\u{05B9}' => "oh", '\u{05BB}' => "u", '\u{05B0}' => "ᵉ", '\u{05B1}' => "ᵉ", '\u{05B2}' => "ᵃ", '\u{05B3}' => "ᵒ", '\u{05C7}' => "o", _ => "",
}
}
fn consonant(c: char, dagesh: bool, sin_dot: bool) -> &'static str {
match c {
'\u{05D0}' | '\u{05E2}' => "'", '\u{05D1}' => {
if dagesh {
"b"
} else {
"v"
}
}
'\u{05D2}' => "g",
'\u{05D3}' => "d",
'\u{05D4}' => "h",
'\u{05D5}' => "v", '\u{05D6}' => "z",
'\u{05D7}' => "ch",
'\u{05D8}' => "t",
'\u{05D9}' => "y",
'\u{05DA}' | '\u{05DB}' => {
if dagesh {
"k"
} else {
"kh"
}
}
'\u{05DC}' => "l",
'\u{05DD}' | '\u{05DE}' => "m",
'\u{05DF}' | '\u{05E0}' => "n",
'\u{05E1}' => "s",
'\u{05E3}' | '\u{05E4}' => {
if dagesh {
"p"
} else {
"f"
}
}
'\u{05E5}' | '\u{05E6}' => "ts",
'\u{05E7}' => "k",
'\u{05E8}' => "r",
'\u{05E9}' => {
if sin_dot {
"s"
} else {
"sh"
}
}
'\u{05EA}' => "t",
_ => "",
}
}
fn clusters(text: &str) -> Vec<Vec<char>> {
let mut out: Vec<Vec<char>> = Vec::new();
for c in text.chars() {
if is_base(c) {
out.push(vec![c]);
} else if is_mark(c) {
if let Some(last) = out.last_mut()
&& is_base(last[0])
{
last.push(c);
}
} else if c == ' ' {
out.push(vec![' ']);
} else if c == '\u{05BE}' {
out.push(vec!['\u{05BE}']); }
}
out
}
fn word_final(clusters: &[Vec<char>], k: usize) -> bool {
match clusters.get(k + 1) {
Some(next) => !is_base(next[0]),
None => true,
}
}
pub fn consonant_onset(glyph: &str) -> &'static str {
let mut chars = glyph.chars();
let Some(c) = chars.next() else { return "" };
if !is_base(c) {
return "";
}
let dagesh = glyph.chars().any(|m| m == '\u{05BC}');
let sin_dot = glyph.chars().any(|m| m == '\u{05C2}');
consonant(c, dagesh, sin_dot)
}
pub fn voiced_syllable(host: &str, vowel: char) -> String {
format!("{}{}", consonant_onset(host), vowel_vocalisation(vowel))
}
pub fn voiced_syllable_str(syllable: &str) -> String {
match syllable
.chars()
.find(|&c| !vowel_vocalisation(c).is_empty())
{
Some(v) => voiced_syllable(syllable, v),
None => consonant_onset(syllable).to_string(),
}
}
fn is_short_vowel(m: char) -> bool {
matches!(m, '\u{05B4}' | '\u{05B6}' | '\u{05B7}' | '\u{05BB}')
}
pub fn romanize(text: &str) -> String {
let clusters = clusters(text);
let mut out = String::new();
let mut last_vowel = "";
let mut last_short = false;
for (k, cl) in clusters.iter().enumerate() {
let base = cl[0];
if base == ' ' {
out.push(' ');
last_vowel = "";
last_short = false;
continue;
}
if base == '\u{05BE}' {
out.push('-');
last_vowel = "";
last_short = false;
continue;
}
let marks = &cl[1..];
let dagesh = marks.contains(&'\u{05BC}');
let sin_dot = marks.contains(&'\u{05C2}');
let is_last = word_final(&clusters, k);
let silent_sheva = marks.contains(&'\u{05B0}') && (is_last || (last_short && !dagesh));
let vowels: Vec<&str> = marks
.iter()
.filter(|&&m| !(silent_sheva && m == '\u{05B0}'))
.map(|&m| vowel(m))
.filter(|v| !v.is_empty())
.collect();
let vstr = vowels.concat();
if base == '\u{05D5}' {
if marks.contains(&'\u{05B9}') {
out.push('o');
last_vowel = "o";
last_short = false;
continue;
}
if dagesh && vowels.is_empty() {
out.push('u');
last_vowel = "u";
last_short = false;
continue;
}
if marks.is_empty() && !is_last {
out.push_str("vo");
last_vowel = "o";
last_short = false;
continue;
}
}
if base == '\u{05D9}' && vowels.is_empty() && !dagesh {
if last_vowel == "i" {
last_short = false;
continue;
}
if last_vowel == "a"
&& clusters
.get(k + 1)
.is_some_and(|n| n.len() == 1 && n[0] == '\u{05D5}')
&& word_final(&clusters, k + 1)
{
continue;
}
}
if is_last
&& marks.contains(&'\u{05B7}')
&& (base == '\u{05D7}' || base == '\u{05E2}' || base == '\u{05D4}')
{
out.push('a');
out.push_str(consonant(base, dagesh, sin_dot));
last_vowel = "";
last_short = false;
continue;
}
out.push_str(consonant(base, dagesh, sin_dot));
out.push_str(&vstr);
last_vowel = vowels.last().copied().unwrap_or("");
last_short = marks
.iter()
.rev()
.copied()
.find(|&m| !vowel(m).is_empty() || m == '\u{05B0}')
.is_some_and(is_short_vowel);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn common_words_read_naturally() {
assert_eq!(romanize("שָׁלוֹם"), "shalom");
assert_eq!(romanize("בַּיִת"), "bayit");
assert_eq!(romanize("תּוֹרָה"), "torah"); assert_eq!(romanize("בְּרֵאשִׁית"), "bere'shit"); }
#[test]
fn dagesh_hardens_begadkefat() {
assert_eq!(romanize("בּ"), "b");
assert_eq!(romanize("ב"), "v");
assert_eq!(romanize("כּ"), "k");
assert_eq!(romanize("כ"), "kh");
}
#[test]
fn sin_vs_shin_dot() {
assert_eq!(romanize("שׁ"), "sh");
assert_eq!(romanize("שׂ"), "s");
}
#[test]
fn furtive_patah_is_read_before_the_guttural() {
assert_eq!(romanize("רוּחַ"), "ruach");
}
#[test]
fn maqaf_becomes_a_hyphen() {
assert_eq!(romanize("עַל־כֵּן"), "'al-ken"); }
#[test]
fn silent_sheva_is_dropped() {
assert_eq!(romanize("לָךְ"), "lakh");
assert_eq!(romanize("עִמָּךְ"), "'imakh");
assert_eq!(romanize("מִדְבָּר"), "midbar");
assert_eq!(romanize("בְּרֵאשִׁית"), "bere'shit");
assert_eq!(romanize("הַמְּלָכִים"), "hamelakhim");
}
#[test]
fn silent_yod_of_the_3ms_plural_suffix() {
assert_eq!(romanize("עָלָיו"), "'alav");
assert_eq!(romanize("אֵלָיו"), "'elav");
assert_eq!(romanize("דְּבָרָיו"), "devarav");
assert_eq!(romanize("גּוֹי"), "goy");
}
#[test]
fn defective_holam_on_consonantal_vav() {
assert_eq!(romanize("עָון"), "'avon");
assert_eq!(romanize("מִצְות"), "mitsvot");
assert_eq!(romanize("מִצְותָיו"), "mitsvotav");
}
#[test]
fn final_kaf_and_pe_honour_dagesh() {
assert_eq!(romanize("מִמֶּךָּ"), "mimeka"); assert_eq!(romanize("מִמְּךָ"), "mimekha"); }
#[test]
fn spaces_between_words_are_kept() {
assert_eq!(romanize("בַּיִת שָׁלוֹם"), "bayit shalom");
}
#[test]
fn cantillation_is_ignored() {
assert_eq!(romanize("בְּרֵאשִׁ֖ית"), "bere'shit");
}
#[test]
fn onset_voices_a_lone_consonant() {
assert_eq!(consonant_onset("ה"), "h");
assert_eq!(consonant_onset("מ"), "m");
assert_eq!(consonant_onset("ח"), "ch");
assert_eq!(consonant_onset("ב"), "v"); assert_eq!(consonant_onset("א"), "'"); assert_eq!(consonant_onset("ע"), "'"); }
#[test]
fn onset_honours_dot_marks_carried_by_the_glyph() {
assert_eq!(consonant_onset("בּ"), "b"); assert_eq!(consonant_onset("כּ"), "k");
assert_eq!(consonant_onset("פּ"), "p");
assert_eq!(consonant_onset("שׁ"), "sh"); assert_eq!(consonant_onset("שׂ"), "s"); }
#[test]
fn non_consonants_have_no_onset() {
assert_eq!(consonant_onset(""), "");
assert_eq!(consonant_onset("ֶ"), ""); }
#[test]
fn voiced_syllables_keep_vowel_lengths_apart() {
assert_eq!(voiced_syllable("בּ", '\u{05B8}'), "bah"); assert_eq!(voiced_syllable("בּ", '\u{05B7}'), "ba"); assert_eq!(voiced_syllable("ב", '\u{05B8}'), "vah"); assert_eq!(voiced_syllable("שׂ", '\u{05B9}'), "soh"); assert_eq!(voiced_syllable("שׁ", '\u{05B0}'), "shᵉ"); assert_eq!(voiced_syllable("ח", '\u{05B2}'), "chᵃ"); }
#[test]
fn voiced_syllable_str_splits_marks_from_the_vowel() {
assert_eq!(voiced_syllable_str("בְּ"), "bᵉ"); assert_eq!(voiced_syllable_str("שֹׂ"), "soh"); assert_eq!(voiced_syllable_str("רֶ"), "re");
assert_eq!(voiced_syllable_str("ב"), "v"); assert_eq!(voiced_syllable_str(""), "");
}
}