pub mod ipa_table;
pub mod phoneme_ipa;
pub mod compat;
pub mod ssml;
use std::path::{Path, PathBuf};
pub fn default_data_dir() -> String {
if let Ok(path) = std::env::var("ESPEAK_DATA_PATH") {
return path;
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let local = dir.join("espeak-ng-data");
if local.join("en_dict").exists() {
return local.to_string_lossy().into_owned();
}
}
}
{
let cwd_local = std::path::Path::new("espeak-ng-data");
if cwd_local.join("en_dict").exists() {
if let Ok(abs) = cwd_local.canonicalize() {
return abs.to_string_lossy().into_owned();
}
}
}
"/usr/share/espeak-ng-data".to_string()
}
pub fn normalize_voice_tag(s: &str) -> String {
s.trim().replace('_', "-").to_ascii_lowercase()
}
pub fn split_voice_variant(voice: &str) -> (&str, Option<&str>) {
match voice.split_once('+') {
Some((base, variant)) if !base.is_empty() => {
(base, (!variant.is_empty()).then_some(variant))
}
_ => (voice, None),
}
}
pub(crate) fn primary_bcp47_subtag(tag: &str) -> &str {
tag.split('-').find(|part| !part.is_empty()).unwrap_or(tag)
}
pub fn dict_path(data_dir: &Path, stem: &str) -> Option<std::path::PathBuf> {
let flat = data_dir.join(format!("{stem}_dict"));
if flat.exists() {
return Some(flat);
}
let nested = data_dir.join("dicts").join(format!("{stem}_dict"));
nested.exists().then_some(nested)
}
pub fn resolve_dict_stem(data_dir: &Path, voice_tag: &str) -> Option<String> {
let parts: Vec<&str> = voice_tag
.split('-')
.filter(|p| !p.is_empty())
.collect();
if parts.is_empty() {
return None;
}
for len in (1..=parts.len()).rev() {
let stem = parts[..len].join("-");
if dict_path(data_dir, &stem).is_some() {
return Some(stem);
}
}
let (dictionary, _) = crate::voices::voice_data_overrides(data_dir, voice_tag);
if let Some(dict) = dictionary {
if dict_path(data_dir, &dict).is_some() {
return Some(dict);
}
}
None
}
pub fn resolve_phoneme_table(data_dir: &Path, voice_tag: &str) -> String {
let (_, phonemes) = crate::voices::voice_data_overrides(data_dir, voice_tag);
phonemes.unwrap_or_else(|| voice_tag.to_string())
}
pub fn select_phoneme_table(
phdata: &mut PhonemeData,
data_dir: &Path,
lang: &str,
) -> Result<()> {
let resolved = resolve_phoneme_table(data_dir, lang);
let base = primary_bcp47_subtag(lang);
for candidate in [resolved.as_str(), lang, base] {
if phdata.select_table_by_name(candidate).is_ok() {
return Ok(());
}
}
phdata.select_table_by_name(base).map(|_| ())
}
fn resolve_voice_lang(selector: &str, data_dir: &Path) -> Option<String> {
if resolve_dict_stem(data_dir, selector).is_some() {
return Some(selector.to_string());
}
let voices = crate::voices::list_voices(data_dir);
let query = crate::voices::VoiceQuery { name: Some(selector.to_string()), ..Default::default() };
crate::voices::find_voice(&voices, &query)
.and_then(|v| resolve_dict_stem(data_dir, &v.language))
}
use crate::error::{Error, Result};
use crate::phoneme::load::PhonemeData;
use crate::dictionary::file::Dictionary;
use crate::dictionary::lookup::{lookup, LookupCtx};
use crate::dictionary::rules::is_letter_wc;
use crate::dictionary::rules::translate_rules_phdata;
use crate::dictionary::{
FLAG_PREFIX_REMOVED, FLAG_SUFX, FLAG_SUFX_E_ADDED, FLAG_SUFFIX_REMOVED, FLAG_SUFFIX_VOWEL,
FLAG_SUFX_S, LETTERGP_B, LETTERGP_VOWEL2, SUFX_A, SUFX_E, SUFX_I, SUFX_M, SUFX_P,
};
use crate::dictionary::stress::{set_word_stress, promote_strend_stress, change_word_stress,
apply_word_final_devoicing, apply_alt_stress_upgrade, StressOpts};
use ipa_table::{
en_ipa_override,
phoneme_ipa_lang,
IPA_STRESS_PRIMARY, IPA_STRESS_SECONDARY,
PendingStress, PHON_STRESS_P, PHON_STRESS_P2, PHON_STRESS_TONIC,
PHON_STRESS_2, PHON_STRESS_3,
PHON_STRESS_U, PHON_STRESS_D, PHON_STRESS_PREV,
is_pause_code,
};
bitflags::bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ClauseFlags: u32 {
const PAUSE_MASK = 0x0000_0FFF;
const INTONATION_MASK = 0x0000_7000;
const OPTIONAL_SPACE_AFTER = 0x0000_8000;
const TYPE_MASK = 0x000F_0000;
const PUNCT_IN_WORD = 0x0010_0000;
const SPEAK_PUNCT_NAME = 0x0020_0000;
const DOT_AFTER_LAST_WORD = 0x0040_0000;
const PAUSE_LONG = 0x0080_0000;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Intonation {
FullStop,
Comma,
Question,
Exclamation,
None,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ClauseType {
None,
Eof,
VoiceChange,
Clause,
Sentence,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum ClauseTerminator {
None,
Comma,
Period,
Question,
Exclamation,
}
impl ClauseTerminator {
pub fn as_char(self) -> Option<char> {
match self {
ClauseTerminator::None => None,
ClauseTerminator::Comma => Some(','),
ClauseTerminator::Period => Some('.'),
ClauseTerminator::Question => Some('?'),
ClauseTerminator::Exclamation => Some('!'),
}
}
pub fn is_sentence(self) -> bool {
matches!(
self,
ClauseTerminator::Period | ClauseTerminator::Question | ClauseTerminator::Exclamation
)
}
pub fn from_char(c: char) -> Self {
match c {
'.' | '\u{3002}' => ClauseTerminator::Period,
'?' | '\u{ff1f}' => ClauseTerminator::Question,
'!' | '\u{ff01}' => ClauseTerminator::Exclamation,
_ => ClauseTerminator::Comma,
}
}
}
fn clause_terminator_of(text: &str) -> ClauseTerminator {
for c in text.chars().rev() {
if c.is_whitespace() || matches!(c, '"' | '\'' | ')' | ']' | '}' | '\u{201d}' | '\u{2019}') {
continue;
}
return match c {
'.' | '?' | '!' | ',' | ';' | ':' | '\u{3002}' | '\u{ff1f}' | '\u{ff01}'
| '\u{ff0c}' | '\u{2026}' => ClauseTerminator::from_char(c),
_ => ClauseTerminator::None,
};
}
ClauseTerminator::None
}
#[derive(Debug, Clone)]
pub struct Clause {
pub text: String,
pub intonation: Intonation,
pub clause_type: ClauseType,
pub pause_ms: u32,
}
#[derive(Debug, Clone)]
pub struct LangOptions {
pub lang: String,
pub rate: u32,
pub pitch: u32,
pub word_gap: i32,
pub lang_word_gap: u8,
pub stress_rule: u8,
pub capitals: u8,
pub punct: Option<Vec<char>>,
pub number_grammar: NumberGrammar,
pub dict_condition: u32,
pub reduce_dictionary_vowels: bool,
pub reversed_textmode: bool,
pub max_initial_consonants: usize,
pub unpronouncable: u32,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NumberGrammar {
pub ordinals: OrdinalGrammar,
pub tens: TensGrammar,
pub hundreds: HundredsGrammar,
pub thousands: ThousandsGrammar,
pub tone_numbers: bool,
pub fraction_digits_as_number: u8,
pub fraction_suffix: bool,
pub fraction_feminine: bool,
pub group_separator: Option<char>,
pub decimal_separator: char,
pub vigesimal_70_90: bool,
pub space_group: bool,
pub portuguese_cardinals: bool,
pub caps_word_split: bool,
pub caps_mark_stress: bool,
pub dutch_ij: bool,
pub combining_one: Option<String>,
pub elide_tens_vowel: bool,
pub thousands_variant: SlavicThousands,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SlavicThousands {
#[default]
None,
Ru,
Cs,
Pl,
Lt,
Sk,
Hr,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct OrdinalGrammar {
pub indicator: Option<String>,
pub dot_marks_ordinal: bool,
pub french: bool,
pub italian: bool,
pub compound_cardinal_suffix: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TensGrammar {
#[default]
Standard,
WithConjunction,
UnitsThenConjunction,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HundredsGrammar {
pub use_conjunction_with_remainder: bool,
pub conjunction_before_simple_remainder: bool,
pub omit_one_prefix: bool,
pub omit_one_hundred_word: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct ThousandsGrammar {
pub omit_one_prefix: bool,
}
impl NumberGrammar {
fn for_lang(lang: &str) -> Self {
let mut grammar = Self::default();
let lang = primary_bcp47_subtag(lang);
match lang {
"en" => {
grammar.hundreds.use_conjunction_with_remainder = true;
grammar.group_separator = Some(','); }
"es" => {
grammar.tens = TensGrammar::WithConjunction;
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"fr" => {
grammar.ordinals.french = true; grammar.hundreds.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.vigesimal_70_90 = true; }
"it" => {
grammar.hundreds.omit_one_prefix = true;
grammar.ordinals.italian = true; grammar.elide_tens_vowel = true; grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"de" => {
grammar.ordinals.dot_marks_ordinal = true;
grammar.ordinals.compound_cardinal_suffix = true;
grammar.tens = TensGrammar::UnitsThenConjunction;
grammar.combining_one = Some("ein".to_string());
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"cmn" | "yue" | "hak" | "zh" => {
grammar.tone_numbers = true;
}
"ru" | "bg" | "uk" | "be" => {
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
grammar.decimal_separator = ',';
if lang == "ru" {
grammar.fraction_suffix = true;
grammar.fraction_feminine = true;
grammar.thousands_variant = SlavicThousands::Ru;
grammar.thousands.omit_one_prefix = false;
}
}
"tr" | "hu" => {
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.fraction_suffix = lang == "hu";
}
"sr" => {
grammar.hundreds.omit_one_prefix = true;
grammar.decimal_separator = ',';
}
"hr" => {
grammar.hundreds.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.thousands_variant = SlavicThousands::Hr;
}
"sk" => {
grammar.hundreds.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.thousands_variant = SlavicThousands::Sk;
}
"mk" => {
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
grammar.decimal_separator = ',';
}
"ar" | "fa" | "ur" => {
grammar.group_separator = Some(',');
}
"nl" | "mt" => {
grammar.ordinals.dot_marks_ordinal = true;
grammar.ordinals.indicator = Some("e".to_string());
grammar.ordinals.compound_cardinal_suffix = lang == "nl";
grammar.tens = TensGrammar::UnitsThenConjunction;
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"pt" => {
grammar.portuguese_cardinals = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"da" | "fo" | "sl" => {
grammar.ordinals.dot_marks_ordinal = true;
grammar.tens = TensGrammar::UnitsThenConjunction;
grammar.ordinals.compound_cardinal_suffix = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"et" | "fi" | "kl" | "nb" | "no" => {
grammar.ordinals.dot_marks_ordinal = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"lt" => {
grammar.ordinals.dot_marks_ordinal = true;
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.thousands_variant = SlavicThousands::Lt;
}
"ro" | "is" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.tens = TensGrammar::WithConjunction;
}
"sq" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.tens = TensGrammar::WithConjunction;
grammar.hundreds.use_conjunction_with_remainder = true;
}
"hy" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.hundreds.omit_one_prefix = true;
}
"kk" => {
grammar.hundreds.omit_one_prefix = true;
}
"az" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.hundreds.omit_one_prefix = true;
grammar.thousands.omit_one_prefix = true;
}
"af" => {
grammar.tens = TensGrammar::UnitsThenConjunction;
grammar.hundreds.conjunction_before_simple_remainder = true;
}
"cs" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.thousands_variant = SlavicThousands::Cs;
}
"pl" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
grammar.thousands_variant = SlavicThousands::Pl;
}
"sv" | "el" | "lv" | "ca" | "eu"
| "bs" | "id" | "vi" => {
grammar.decimal_separator = ',';
grammar.group_separator = Some('.');
}
"cy" => {
grammar.hundreds.omit_one_prefix = true;
}
"am" => {
grammar.hundreds.omit_one_prefix = true;
}
"sw" => {
grammar.tens = TensGrammar::WithConjunction;
grammar.hundreds.conjunction_before_simple_remainder = true;
}
"ml" => {
grammar.hundreds.omit_one_hundred_word = true;
grammar.thousands.omit_one_prefix = true;
}
"ta" | "si" => {
grammar.thousands.omit_one_prefix = true;
}
_ => {}
}
grammar.space_group = grammar.decimal_separator == ',';
grammar.caps_word_split = lang != "ga";
grammar.caps_mark_stress = lang == "jbo";
grammar.dutch_ij = lang == "nl";
grammar.fraction_digits_as_number = match primary_bcp47_subtag(lang) {
"et" | "fi" | "he" | "hr" | "bs" | "sr" | "mk" | "pl" | "pt" | "sk" | "cs"
| "sl" | "smj" | "tr" | "az" => 2,
"es" | "an" | "ca" | "ia" | "pap" | "fr" | "ht" | "lt" | "lv" | "ltg" | "sq"
| "tt" | "vi" => 5,
_ => 0,
};
grammar
}
}
impl Default for NumberGrammar {
fn default() -> Self {
Self {
ordinals: OrdinalGrammar::default(),
tens: TensGrammar::Standard,
hundreds: HundredsGrammar::default(),
thousands: ThousandsGrammar::default(),
tone_numbers: false,
fraction_digits_as_number: 0,
fraction_suffix: false,
fraction_feminine: false,
group_separator: None,
decimal_separator: '.',
vigesimal_70_90: false,
space_group: false,
portuguese_cardinals: false,
caps_word_split: true,
caps_mark_stress: false,
dutch_ij: false,
combining_one: None,
elide_tens_vowel: false,
thousands_variant: SlavicThousands::None,
}
}
}
impl Default for LangOptions {
fn default() -> Self {
LangOptions {
lang: "en".to_string(),
rate: 175,
pitch: 50,
word_gap: 0,
lang_word_gap: 0,
stress_rule: 2, capitals: 0,
punct: None,
number_grammar: NumberGrammar::default(),
dict_condition: 0,
reduce_dictionary_vowels: false,
reversed_textmode: false,
max_initial_consonants: 3,
unpronouncable: 0,
}
}
}
fn punct_covers(opts: &LangOptions, c: char) -> bool {
if c == ssml::SSML_BREAK {
return false;
}
match &opts.punct {
Some(chars) => chars.is_empty() || chars.contains(&c),
None => false,
}
}
impl LangOptions {
pub fn for_lang(lang: &str) -> Self {
let lang = normalize_voice_tag(lang);
let reversed_textmode =
matches!(primary_bcp47_subtag(&lang), "cmn" | "yue" | "zh");
let unpronouncable = match primary_bcp47_subtag(&lang) {
"am" | "ar" | "be" | "chr" | "el" | "grc" | "fa" | "ko" | "si" | "ur" | "sd"
| "cmn" | "yue" | "zh" => 1,
"de" | "en" | "es" | "an" | "ca" | "ia" | "pap" => 2,
"ga" | "gd" => 3,
"bg" => 0x432,
"sl" => 0x76,
_ => 0,
};
let max_initial_consonants = match primary_bcp47_subtag(&lang) {
"az" | "kk" | "ku" | "tr" => 2,
"sw" | "tn" => 4,
"bs" | "cmn" | "cs" | "hr" | "sk" | "sr" | "yue" | "zh" => 5,
"hy" => 6,
"ka" | "pl" => 7,
_ => 3,
};
let lang_word_gap = match primary_bcp47_subtag(&lang) {
"cmn" | "yue" | "zh" | "vi" => 1,
"mn" => 1,
_ => 0,
};
let reduce_dictionary_vowels = primary_bcp47_subtag(&lang) == "it";
Self {
reduce_dictionary_vowels,
number_grammar: NumberGrammar::for_lang(&lang),
reversed_textmode,
max_initial_consonants,
unpronouncable,
lang_word_gap,
lang,
..Default::default()
}
}
}
fn is_cjk_ideograph(c: char) -> bool {
let cp = c as u32;
(0x4E00..=0x9FFF).contains(&cp)
|| (0x3400..=0x4DBF).contains(&cp)
|| (0x20000..=0x323AF).contains(&cp)
|| (0xF900..=0xFAFF).contains(&cp)
|| (0x2F00..=0x2FDF).contains(&cp)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Token {
Word(String),
Number(NumberToken),
Space,
WordJoin,
ClauseBoundary(char),
Punctuation(char),
InlinePhonemes(String),
Embedded(EmbeddedCmd),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NumberToken {
Cardinal(String),
Decimal { integer: String, fractional: String },
Ordinal(OrdinalNumber),
}
impl NumberToken {
fn parse(word: &str, grammar: &NumberGrammar) -> Option<Self> {
if word.is_empty() {
return None;
}
if let Some((integer, fractional)) = word.split_once('.') {
let has_single_dot = word.bytes().filter(|&b| b == b'.').count() == 1;
if has_single_dot
&& !integer.is_empty()
&& !fractional.is_empty()
&& integer.bytes().all(|b| b.is_ascii_digit())
&& fractional.bytes().all(|b| b.is_ascii_digit())
{
return Some(NumberToken::Decimal {
integer: integer.to_string(),
fractional: fractional.to_string(),
});
}
}
let digit_end = word.bytes().position(|b| !b.is_ascii_digit()).unwrap_or(word.len());
if digit_end == 0 {
return None;
}
if digit_end == word.len() {
return word
.bytes()
.all(|b| b.is_ascii_digit())
.then(|| NumberToken::Cardinal(word.to_string()));
}
let digits = &word[..digit_end];
let suffix = &word[digit_end..];
if suffix == "." && grammar.ordinals.dot_marks_ordinal {
return Some(NumberToken::Ordinal(OrdinalNumber {
digits: digits.to_string(),
marker: OrdinalMarker::Dot,
}));
}
Some(NumberToken::Ordinal(OrdinalNumber {
digits: digits.to_string(),
marker: OrdinalMarker::Suffix(suffix.to_lowercase()),
}))
}
fn surface(&self) -> String {
match self {
NumberToken::Cardinal(digits) => digits.clone(),
NumberToken::Decimal { integer, fractional } => format!("{integer}.{fractional}"),
NumberToken::Ordinal(ordinal) => ordinal.surface(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OrdinalNumber {
pub digits: String,
pub marker: OrdinalMarker,
}
impl OrdinalNumber {
fn surface(&self) -> String {
match &self.marker {
OrdinalMarker::Suffix(suffix) => format!("{}{}", self.digits, suffix),
OrdinalMarker::Dot => format!("{}.", self.digits),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrdinalMarker {
Suffix(String),
Dot,
}
pub fn tokenize(text: &str) -> Vec<Token> {
tokenize_opts(text, &NumberGrammar::default())
}
fn is_ordinal_suffix(suffix: &str, digits: &str, grammar: &NumberGrammar) -> bool {
if matches!(suffix, "st" | "nd" | "rd" | "th") {
if suffix == english_ordinal_suffix(digits) {
return true;
}
} else if matches!(suffix, "º" | "ª") {
return true; }
grammar.ordinals.indicator.as_deref() == Some(suffix)
|| (grammar.ordinals.french && is_french_ordinal_suffix(suffix))
}
fn is_french_ordinal_suffix(suffix: &str) -> bool {
matches!(
suffix,
"er" | "ers" | "re" | "res" | "ère" | "ères"
| "e" | "es" | "ème" | "èmes" | "eme" | "emes"
| "nd" | "nds" | "nde" | "ndes"
)
}
fn fr_cardinal_text(n: u32) -> String {
const U: [&str; 20] = [
"zéro", "un", "deux", "trois", "quatre", "cinq", "six", "sept", "huit", "neuf",
"dix", "onze", "douze", "treize", "quatorze", "quinze", "seize",
"dix-sept", "dix-huit", "dix-neuf",
];
if n < 20 {
return U[n as usize].to_string();
}
if n < 60 {
let (tens, u) = (n / 10, n % 10);
let base = match tens {
2 => "vingt",
3 => "trente",
4 => "quarante",
5 => "cinquante",
_ => unreachable!(),
};
return match u {
0 => base.to_string(),
1 => format!("{base} et un"),
_ => format!("{base}-{}", U[u as usize]),
};
}
if n < 80 {
return match n - 60 {
0 => "soixante".to_string(),
1 => "soixante et un".to_string(),
11 => "soixante et onze".to_string(),
inner => format!("soixante-{}", U[inner as usize]),
};
}
if n < 100 {
return match n - 80 {
0 => "quatre-vingts".to_string(),
inner => format!("quatre-vingt-{}", U[inner as usize]),
};
}
let (h, rest) = (n / 100, n % 100);
let mut head = if h == 1 { "cent".to_string() } else { format!("{} cent", U[h as usize]) };
if h > 1 && rest == 0 {
head.push('s'); }
if rest == 0 {
head
} else {
format!("{head} {}", fr_cardinal_text(rest))
}
}
fn ordinalize_fr_word(w: &str) -> String {
match w {
"un" => return "unième".to_string(),
"cinq" => return "cinquième".to_string(),
"neuf" => return "neuvième".to_string(),
"vingt" | "vingts" => return "vingtième".to_string(),
"cent" | "cents" => return "centième".to_string(),
_ => {}
}
let stem = w.strip_suffix('e').unwrap_or(w);
format!("{stem}ième")
}
fn french_ordinal_word(digits: &str, suffix: &str) -> Option<String> {
let n: u32 = digits.parse().ok()?;
if n == 0 {
return None;
}
if n == 1 {
let feminine = matches!(suffix, "re" | "res" | "ère" | "ères");
return Some(if feminine { "première" } else { "premier" }.to_string());
}
if n == 2 && matches!(suffix, "nd" | "nds" | "nde" | "ndes") {
let feminine = matches!(suffix, "nde" | "ndes");
return Some(if feminine { "seconde" } else { "second" }.to_string());
}
if n == 1000 {
return Some("millième".to_string());
}
if n > 999 {
return None;
}
let card = fr_cardinal_text(n);
let (head, last) = match card.rfind(['-', ' ']) {
Some(i) => (&card[..=i], &card[i + 1..]),
None => ("", card.as_str()),
};
Some(format!("{head}{}", ordinalize_fr_word(last)))
}
fn italian_ordinal_word(digits: &str, suffix: &str) -> Option<String> {
let n: u32 = digits.parse().ok()?;
if !(1..=10).contains(&n) {
return None;
}
const MASC: [&str; 10] = [
"primo", "secondo", "terzo", "quarto", "quinto",
"sesto", "settimo", "ottavo", "nono", "decimo",
];
const FEM: [&str; 10] = [
"prima", "seconda", "terza", "quarta", "quinta",
"sesta", "settima", "ottava", "nona", "decima",
];
let table = if suffix == "ª" { &FEM } else { &MASC };
Some(table[(n - 1) as usize].to_string())
}
fn pt_group(n: u32) -> String {
const U: [&str; 20] = [
"zero", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove",
"dez", "onze", "doze", "treze", "catorze", "quinze",
"dezasseis", "dezassete", "dezoito", "dezanove",
];
const T: [&str; 10] = [
"", "", "vinte", "trinta", "quarenta", "cinquenta",
"sessenta", "setenta", "oitenta", "noventa",
];
const H: [&str; 10] = [
"", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos",
"seiscentos", "setecentos", "oitocentos", "novecentos",
];
let tens_units = |tu: u32| -> String {
if tu < 20 {
U[tu as usize].to_string()
} else {
let (t, u) = ((tu / 10) as usize, tu % 10);
if u == 0 {
T[t].to_string()
} else {
format!("{} e {}", T[t], U[u as usize])
}
}
};
if n == 0 {
return String::new();
}
if n == 100 {
return "cem".to_string(); }
let mut parts: Vec<String> = Vec::new();
if n / 100 > 0 {
parts.push(H[(n / 100) as usize].to_string());
}
if n % 100 > 0 {
parts.push(tens_units(n % 100));
}
parts.join(" e ")
}
fn portuguese_cardinal_word(digits: &str) -> Option<String> {
let n: u64 = digits.parse().ok()?;
if n > 999_999_999 {
return None;
}
if n == 0 {
return Some("zero".to_string());
}
let millions = (n / 1_000_000) as u32;
let thousands = ((n / 1000) % 1000) as u32;
let units = (n % 1000) as u32;
let mut pieces: Vec<(u32, String)> = Vec::new();
if millions > 0 {
pieces.push((
millions,
if millions == 1 { "um milhão".to_string() } else { format!("{} milhões", pt_group(millions)) },
));
}
if thousands > 0 {
pieces.push((
thousands,
if thousands == 1 { "mil".to_string() } else { format!("{} mil", pt_group(thousands)) },
));
}
if units > 0 {
pieces.push((units, pt_group(units)));
}
let last = pieces.len() - 1;
let mut out = String::new();
for (i, (value, text)) in pieces.iter().enumerate() {
if i > 0 {
let use_e = i == last && (*value < 100 || *value % 100 == 0);
out.push_str(if use_e { " e " } else { " " });
}
out.push_str(text);
}
Some(out)
}
fn portuguese_number_word(token: &NumberToken) -> Option<String> {
match token {
NumberToken::Cardinal(digits) => portuguese_cardinal_word(digits),
NumberToken::Decimal { integer, fractional } => {
let mut s = portuguese_cardinal_word(integer)?;
s.push_str(" vírgula");
let zeros = fractional.bytes().take_while(|&b| b == b'0').count();
let rest = &fractional[zeros..];
if !rest.is_empty() && rest.len() <= 2 {
for _ in 0..zeros {
s.push(' ');
s.push_str(&portuguese_cardinal_word("0")?);
}
s.push(' ');
s.push_str(&portuguese_cardinal_word(rest)?);
} else {
for d in fractional.chars() {
s.push(' ');
s.push_str(&portuguese_cardinal_word(&d.to_string())?);
}
}
Some(s)
}
NumberToken::Ordinal(_) => None,
}
}
fn normalize_number_symbols(text: &str) -> std::borrow::Cow<'_, str> {
fn mapping(c: char) -> Option<&'static str> {
Some(match c {
'\u{2070}' | '⁴' | '⁵' | '⁶' | '⁷' | '⁸' | '⁹' => match c {
'\u{2070}' => " 0", '⁴' => " 4", '⁵' => " 5", '⁶' => " 6",
'⁷' => " 7", '⁸' => " 8", _ => " 9",
},
'¹' => " 1", '²' => " squared ", '³' => " cubed ",
'₀' => " 0", '₁' => " 1", '₂' => " 2", '₃' => " 3", '₄' => " 4",
'₅' => " 5", '₆' => " 6", '₇' => " 7", '₈' => " 8", '₉' => " 9",
'½' => " 1/2 ", '⅓' => " 1/3 ", '⅔' => " 2/3 ", '¼' => " 1/4 ",
'¾' => " 3/4 ", '⅕' => " 1/5 ", '⅖' => " 2/5 ", '⅗' => " 3/5 ",
'⅘' => " 4/5 ", '⅙' => " 1/6 ", '⅚' => " 5/6 ", '⅐' => " 1/7 ",
'⅛' => " 1/8 ", '⅜' => " 3/8 ", '⅝' => " 5/8 ", '⅞' => " 7/8 ",
'⅑' => " 1/9 ", '⅒' => " 1/10 ",
'\u{066B}' => ".", '\u{066C}' => ",", '\u{066A}' => "%", _ => return None,
})
}
let changed = |c: char| mapping(c).is_some()
|| native_digit_to_ascii(c).is_some()
|| fullwidth_to_ascii(c).is_some()
|| stylized_to_ascii(c).is_some()
|| enclosed_number_value(c).is_some()
|| compat::compatibility_expansion(c).is_some();
if !text.chars().any(changed) {
return std::borrow::Cow::Borrowed(text);
}
let mut out = String::with_capacity(text.len() + 8);
for c in text.chars() {
if let Some(s) = mapping(c) {
out.push_str(s);
} else if let Some(d) = native_digit_to_ascii(c) {
out.push(d);
} else if let Some(a) = fullwidth_to_ascii(c) {
out.push(a);
} else if let Some(a) = stylized_to_ascii(c) {
out.push(a);
} else if let Some(n) = enclosed_number_value(c) {
out.push(' ');
out.push_str(&n.to_string());
out.push(' ');
} else if let Some(e) = compat::compatibility_expansion(c) {
out.push_str(e);
} else {
out.push(c);
}
}
std::borrow::Cow::Owned(out)
}
fn enclosed_number_value(c: char) -> Option<u32> {
let cp = c as u32;
Some(match cp {
0x24EA => 0, 0x2460..=0x2473 => cp - 0x245F, 0x2474..=0x2487 => cp - 0x2473, 0x2160..=0x216B => cp - 0x215F, 0x2170..=0x217B => cp - 0x216F, 0x216C | 0x217C => 50, 0x216D | 0x217D => 100, 0x216E | 0x217E => 500, 0x216F | 0x217F => 1000, _ => return None,
})
}
fn stylized_to_ascii(c: char) -> Option<char> {
let cp = c as u32;
if (0x1D400..=0x1D6A3).contains(&cp) {
let idx = (cp - 0x1D400) % 52;
return Some(if idx < 26 {
(b'A' + idx as u8) as char
} else {
(b'a' + (idx - 26) as u8) as char
});
}
if (0x1D7CE..=0x1D7FF).contains(&cp) {
return char::from_digit((cp - 0x1D7CE) % 10, 10);
}
if (0x24B6..=0x24CF).contains(&cp) {
return Some((b'A' + (cp - 0x24B6) as u8) as char);
}
if (0x24D0..=0x24E9).contains(&cp) {
return Some((b'a' + (cp - 0x24D0) as u8) as char);
}
Some(match c {
'ℎ' => 'h', 'ℬ' => 'B', 'ℰ' => 'E', 'ℱ' => 'F', 'ℋ' => 'H', 'ℐ' => 'I',
'ℒ' => 'L', 'ℳ' => 'M', 'ℛ' => 'R', 'ℯ' => 'e', 'ℊ' => 'g', 'ℴ' => 'o',
'ℭ' => 'C', 'ℌ' => 'H', 'ℑ' => 'I', 'ℜ' => 'R', 'ℨ' => 'Z',
'ℂ' => 'C', 'ℍ' => 'H', 'ℕ' => 'N', 'ℙ' => 'P', 'ℚ' => 'Q', 'ℝ' => 'R', 'ℤ' => 'Z',
_ => return None,
})
}
fn fullwidth_to_ascii(c: char) -> Option<char> {
match c as u32 {
cp @ 0xFF01..=0xFF5E => char::from_u32(cp - 0xFEE0),
0x3000 => Some(' '), _ => None,
}
}
fn native_digit_to_ascii(c: char) -> Option<char> {
let cp = c as u32;
let base = match cp {
0x0660..=0x0669 => 0x0660, 0x06F0..=0x06F9 => 0x06F0, 0x0966..=0x096F => 0x0966, 0x09E6..=0x09EF => 0x09E6, 0x0A66..=0x0A6F => 0x0A66, 0x0AE6..=0x0AEF => 0x0AE6, 0x0B66..=0x0B6F => 0x0B66, 0x0BE6..=0x0BEF => 0x0BE6, 0x0C66..=0x0C6F => 0x0C66, 0x0CE6..=0x0CEF => 0x0CE6, 0x0D66..=0x0D6F => 0x0D66, 0x0E50..=0x0E59 => 0x0E50, 0x0ED0..=0x0ED9 => 0x0ED0, 0x0F20..=0x0F29 => 0x0F20, _ => return None,
};
char::from_digit(cp - base, 10)
}
pub const CTRL_EMBEDDED: char = '\u{01}';
const EMBED_LETTERS: &str = "PSARHTIVYMUBF";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EmbeddedCmd {
pub letter: char,
pub value: i32,
pub relative: i8,
}
pub fn parse_embedded_commands(text: &str) -> (String, Vec<EmbeddedCmd>) {
let mut out = String::with_capacity(text.len());
let mut cmds = Vec::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c != CTRL_EMBEDDED {
out.push(c);
continue;
}
let relative = match chars.peek() {
Some('+') => { chars.next(); 1 }
Some('-') => { chars.next(); -1 }
_ => 0,
};
let mut digits = String::new();
while matches!(chars.peek(), Some(d) if d.is_ascii_digit()) {
digits.push(chars.next().unwrap());
}
match chars.next() {
Some(l) if EMBED_LETTERS.contains(l.to_ascii_uppercase()) => {
cmds.push(EmbeddedCmd {
letter: l.to_ascii_uppercase(),
value: digits.parse().unwrap_or(-1),
relative,
});
}
_ => {}
}
}
(out, cmds)
}
pub fn tokenize_opts(text: &str, grammar: &NumberGrammar) -> Vec<Token> {
let text = normalize_number_symbols(text);
let text = text.as_ref();
let mut tokens = Vec::new();
let mut chars = text.chars().peekable();
while let Some(c) = chars.next() {
if c == CTRL_EMBEDDED {
let relative = match chars.peek() {
Some('+') => { chars.next(); 1 }
Some('-') => { chars.next(); -1 }
_ => 0,
};
let mut digits = String::new();
while matches!(chars.peek(), Some(d) if d.is_ascii_digit()) {
digits.push(chars.next().unwrap());
}
match chars.next() {
Some(l) if EMBED_LETTERS.contains(l.to_ascii_uppercase()) => {
tokens.push(Token::Embedded(EmbeddedCmd {
letter: l.to_ascii_uppercase(),
value: digits.parse().unwrap_or(-1),
relative,
}));
}
_ => {}
}
continue;
}
if c.is_whitespace() {
while chars.peek().map(|c| c.is_whitespace()).unwrap_or(false) {
chars.next();
}
tokens.push(Token::Space);
} else if c == ssml::SSML_BREAK {
tokens.push(Token::ClauseBoundary(c));
} else if c == '[' && chars.peek() == Some(&'[') {
chars.next(); let mut content = String::new();
while let Some(nc) = chars.next() {
if nc == ']' {
if chars.peek() == Some(&']') {
chars.next(); break;
}
content.push(']');
} else {
content.push(nc);
}
}
tokens.push(Token::InlinePhonemes(content));
} else if c == grammar.decimal_separator
&& chars.peek().map_or(false, |d| d.is_ascii_digit())
{
let integer = if matches!(tokens.last(), Some(Token::Number(_))) { "" } else { "0" };
let mut fractional = String::new();
while let Some(&d) = chars.peek() {
if d.is_ascii_digit() {
fractional.push(d);
chars.next();
} else {
break;
}
}
tokens.push(Token::Number(NumberToken::Decimal { integer: integer.into(), fractional }));
} else if matches!(c, '.' | ',' | '!' | '?' | ';' | ':') {
if c == '.'
&& matches!(chars.peek(), Some(n) if n.is_alphabetic())
&& matches!(tokens.last(), Some(Token::Word(w)) if is_single_letter_word(w))
{
continue;
}
if c == '.' && matches!(chars.peek(), Some(n) if n.is_alphanumeric()) {
tokens.push(Token::Punctuation('.'));
continue;
}
if c == ':' && matches!(chars.peek(), Some(n) if n.is_ascii_digit()) {
tokens.push(Token::Punctuation(':'));
continue;
}
while chars.peek().map(|ch| ch.is_whitespace()).unwrap_or(false) {
chars.next();
}
tokens.push(Token::ClauseBoundary(c));
} else if c == '-'
&& minus_precedes_number(&chars)
&& matches!(
tokens.last(),
None | Some(Token::Space | Token::WordJoin) | Some(Token::ClauseBoundary(_))
)
{
tokens.push(Token::Punctuation('−'));
} else if c.is_ascii_digit() {
let mut digits = String::new();
digits.push(c);
let mut has_dot = false;
let mut fractional = String::new();
while let Some(&next) = chars.peek() {
if next.is_ascii_digit() {
if has_dot {
fractional.push(next);
} else {
digits.push(next);
}
chars.next();
} else if next == grammar.decimal_separator && !has_dot {
let mut lookahead = chars.clone();
lookahead.next(); if lookahead.peek().map(|c| c.is_ascii_digit()).unwrap_or(false) {
has_dot = true;
chars.next();
} else {
break;
}
} else if grammar.group_separator == Some(next) && !has_dot {
let mut lookahead = chars.clone();
lookahead.next(); let d1 = lookahead.next();
let d2 = lookahead.next();
let d3 = lookahead.next();
let three_digits = [d1, d2, d3]
.iter()
.all(|c| c.map_or(false, |c| c.is_ascii_digit()));
let more_digits = lookahead.peek().map_or(false, |c| c.is_ascii_digit());
if three_digits && !more_digits {
chars.next(); } else {
break;
}
} else {
break;
}
}
if has_dot {
tokens.push(Token::Number(NumberToken::Decimal {
integer: digits,
fractional,
}));
continue;
}
let mut lookahead = chars.clone();
let mut dot_before_indicator = false;
if !grammar.ordinals.dot_marks_ordinal && lookahead.peek() == Some(&'.') {
let mut probe = lookahead.clone();
probe.next(); if matches!(probe.peek(), Some('º') | Some('ª')) {
lookahead.next(); dot_before_indicator = true;
}
}
let mut suffix = String::new();
while let Some(&next) = lookahead.peek() {
if next.is_alphabetic() || next == 'º' || next == 'ª' {
suffix.push(next);
lookahead.next();
} else {
break;
}
}
let suffix_lc = suffix.to_lowercase();
if !suffix.is_empty() && is_ordinal_suffix(&suffix_lc, &digits, grammar) {
if dot_before_indicator {
chars.next(); }
for _ in 0..suffix.chars().count() {
chars.next(); }
tokens.push(Token::Number(NumberToken::Ordinal(OrdinalNumber {
digits,
marker: OrdinalMarker::Suffix(suffix_lc),
})));
continue;
}
if grammar.ordinals.dot_marks_ordinal && chars.peek() == Some(&'.') {
let mut lookahead = chars.clone();
lookahead.next(); let after_dot = lookahead.peek().copied();
if !after_dot.map_or(false, |c| c.is_ascii_digit()) {
chars.next();
tokens.push(Token::Number(NumberToken::Ordinal(OrdinalNumber {
digits,
marker: OrdinalMarker::Dot,
})));
continue;
}
}
tokens.push(Token::Number(NumberToken::Cardinal(digits)));
} else if is_cjk_ideograph(c) {
tokens.push(Token::Word(c.to_string()));
while let Some(&next) = chars.peek() {
if is_cjk_ideograph(next) {
tokens.push(Token::Space);
tokens.push(Token::Word(next.to_string()));
chars.next();
} else {
break;
}
}
} else if c.is_alphabetic() || c == '\'' {
let mut word = String::new();
let mut syllable_marked = false;
if grammar.caps_mark_stress && c.is_uppercase() {
word.push('\u{02c8}');
syllable_marked = true;
}
word.push(c);
let mut prev_char = c;
let mut word_break = false;
let mut hyphen_join = false;
while let Some(&next) = chars.peek() {
if is_cjk_ideograph(next) {
break;
} else if grammar.caps_mark_stress && next.is_uppercase() {
chars.next();
if !syllable_marked {
word.push('\u{02c8}');
syllable_marked = true;
}
word.push(next);
prev_char = next;
} else if grammar.caps_word_split
&& prev_char.is_lowercase()
&& next.is_uppercase()
{
word_break = true;
break;
} else if grammar.caps_word_split
&& prev_char.is_uppercase()
&& next.is_uppercase()
&& caps_run_ends_here(&chars, next, &word, prev_char, grammar.dutch_ij)
{
word_break = true;
break;
} else if grammar.tone_numbers && next.is_ascii_digit() {
word.push(next);
chars.next();
prev_char = next;
} else if next.is_alphabetic() || next == '\'' || is_indic_combining(next) {
word.push(next);
chars.next();
prev_char = next;
} else if next == '-' {
let mut lookahead = chars.clone();
lookahead.next(); if lookahead.peek().map(|c| c.is_alphabetic()).unwrap_or(false) {
chars.next(); word_break = true;
hyphen_join = true;
break;
} else {
break;
}
} else {
break;
}
}
tokens.push(Token::Word(word));
if word_break {
tokens.push(if hyphen_join { Token::WordJoin } else { Token::Space });
}
} else if is_emoji_char(c) {
let mut emoji = String::from(c);
let is_regional = |ch: char| (0x1F1E6..=0x1F1FF).contains(&(ch as u32));
if is_regional(c) {
if matches!(chars.peek(), Some(&n) if is_regional(n)) {
emoji.push(chars.next().unwrap());
}
} else {
while let Some(&m) = chars.peek() {
if is_emoji_tag(m) {
chars.next();
emoji.push(m);
} else if is_emoji_modifier(m) {
chars.next();
if m != '\u{FE0F}' {
emoji.push(m);
}
if m == '\u{200D}' {
if matches!(chars.peek(), Some(&n) if is_emoji_char(n)) {
emoji.push(chars.next().unwrap());
}
}
} else {
break;
}
}
}
tokens.push(Token::Word(emoji));
} else {
tokens.push(Token::Punctuation(c));
}
}
tokens
}
fn caps_run_ends_here<I>(
chars: &std::iter::Peekable<I>,
next: char,
word: &str,
prev_char: char,
dutch_ij: bool,
) -> bool
where
I: Iterator<Item = char> + Clone,
{
if dutch_ij && prev_char == 'I' && next == 'J' {
let letters = word.chars().filter(|c| c.is_alphabetic()).count();
if letters == 1 {
return false;
}
}
let mut la = chars.clone();
la.next(); let after = la.next();
let after2 = la.next();
after.map(|c| c.is_lowercase()).unwrap_or(false)
&& after2.map(|c| c.is_alphabetic()).unwrap_or(false)
}
fn delete_final_schwa(phonemes: &mut Vec<u8>, phdata: &PhonemeData) {
use crate::phoneme::{PH_PAUSE, PH_VOWEL, PHON_END_WORD};
let v = phdata.lookup_phoneme("V");
if v == 0 {
return;
}
let had_null = phonemes.last() == Some(&0);
if had_null {
phonemes.pop();
}
let is_marker = |c: u8| (1..=8).contains(&c) || c == 26 || c == PHON_END_WORD;
if phonemes.last() == Some(&v) {
let body = &phonemes[..phonemes.len() - 1];
let prev_is_consonant = body
.iter()
.rev()
.find(|&&c| !is_marker(c))
.and_then(|&c| phdata.get(c))
.map(|p| p.typ != PH_VOWEL && p.typ != PH_PAUSE)
.unwrap_or(false);
let has_other_vowel = body
.iter()
.any(|&c| phdata.get(c).map(|p| p.typ == PH_VOWEL).unwrap_or(false));
if prev_is_consonant && has_other_vowel {
phonemes.pop(); if matches!(phonemes.last(), Some(&c) if is_marker(c)) {
phonemes.pop(); }
}
}
if had_null {
phonemes.push(0);
}
}
fn is_indic_combining(c: char) -> bool {
matches!(c as u32,
0x093C | 0x094D | 0x09BC | 0x09CD | 0x0A3C | 0x0A4D | 0x0ABC | 0x0ACD | 0x0B3C | 0x0B4D | 0x0BCD | 0x0C3C | 0x0C4D | 0x0CBC | 0x0CCD | 0x0D3B | 0x0D3C | 0x0D4D | 0x0DCA | 0x200C | 0x200D
)
}
fn is_emoji_char(c: char) -> bool {
matches!(c as u32,
0x1F000..=0x1FAFF | 0x2600..=0x27BF | 0x2B00..=0x2BFF )
}
fn is_emoji_modifier(c: char) -> bool {
matches!(c as u32, 0xFE0F | 0x1F3FB..=0x1F3FF | 0x200D)
}
fn is_skin_tone(c: char) -> bool {
matches!(c as u32, 0x1F3FB..=0x1F3FF)
}
fn is_emoji_tag(c: char) -> bool {
matches!(c as u32, 0xE0020..=0xE007F)
}
pub fn english_letter_bits() -> [u8; 256] {
let mut bits = [0u8; 256];
let set = |bits: &mut [u8; 256], group: u8, letters: &[u8]| {
for &c in letters {
bits[c as usize] |= 1 << group;
if c.is_ascii_lowercase() {
bits[(c - 32) as usize] |= 1 << group;
}
}
};
set(&mut bits, 0, b"aeiou");
set(&mut bits, 1, b"bcdfgjklmnpqstvxz");
set(&mut bits, 2, b"bcdfghjklmnpqrstvwxz");
set(&mut bits, 3, b"hlmnr");
set(&mut bits, 4, b"cfhkpqstx");
set(&mut bits, 5, b"bdgjlmnrvwyz");
set(&mut bits, 6, b"eiy");
set(&mut bits, 7, b"aeiouy");
bits
}
pub fn phonemes_to_ipa(
phoneme_bytes: &[u8],
phdata: &PhonemeData,
pending_stress_in: PendingStress,
word_sep: bool, ) -> (String, PendingStress) {
phonemes_to_ipa_lang(phoneme_bytes, phdata, pending_stress_in, word_sep, true)
}
pub fn phonemes_to_ipa_lang(
phoneme_bytes: &[u8],
phdata: &PhonemeData,
pending_stress_in: PendingStress,
word_sep: bool,
use_en_overrides: bool,
) -> (String, PendingStress) {
phonemes_to_ipa_full(
phoneme_bytes, phdata, pending_stress_in, word_sep, use_en_overrides,
LiaisonCtx::default(),
)
}
fn liaison_skip(c: u8) -> bool {
(2..=8).contains(&c) || c == PHON_STRESS_TONIC || c == PHON_END_WORD || is_pause_code(c)
}
#[derive(Clone, Copy, Default)]
pub struct LiaisonCtx {
pub resolve: bool,
pub next_starts_vowel: bool,
pub next_is_pause: bool,
pub reduce_dict_vowels: bool,
pub translation_given: bool,
pub next_phoneme: u8,
}
fn resolve_virtual_phoneme(
code: u8,
idx: usize,
phoneme_bytes: &[u8],
prev_phcode: u8,
phdata: &PhonemeData,
next_word_phoneme: u8,
stress_level: u8,
translation_given: bool,
) -> Option<u8> {
phoneme_program_effects(
code, idx, phoneme_bytes, prev_phcode, phdata, next_word_phoneme, stress_level,
translation_given,
)
.change_phoneme_code
.filter(|&c| c != 0 && c != code)
}
fn phoneme_program_effects(
code: u8,
idx: usize,
phoneme_bytes: &[u8],
prev_phcode: u8,
phdata: &PhonemeData,
next_word_phoneme: u8,
stress_level: u8,
translation_given: bool,
) -> crate::synthesize::bytecode::PhonemeExtract {
let empty = crate::synthesize::bytecode::PhonemeExtract::default();
let Some(ph) = phdata.get(code) else { return empty };
let program = ph.program;
if program == 0 {
return empty;
}
let mut following = phoneme_bytes[idx + 1..]
.iter()
.copied()
.take_while(|&c| c != 0)
.filter(|&c| !liaison_skip(c));
let in_token_next = following.next();
let crosses_word = in_token_next.is_none();
let next = in_token_next.unwrap_or(next_word_phoneme);
let nb = crate::synthesize::bytecode::Neighbours {
prev: prev_phcode,
this: code,
next,
next2: following.next().unwrap_or(0),
stress: stress_level,
translation_given,
next_wordstart: crosses_word,
next2_wordstart: crosses_word,
..Default::default()
};
crate::synthesize::bytecode::interpret_phoneme_ctl(
program,
&phdata.phonindex,
&nb,
|c| phdata.get(c).cloned(),
true, )
}
pub fn phonemes_to_ipa_full(
phoneme_bytes: &[u8],
phdata: &PhonemeData,
pending_stress_in: PendingStress,
word_sep: bool,
use_en_overrides: bool,
liaison: LiaisonCtx,
) -> (String, PendingStress) {
let mut out = String::new();
let mut stress = pending_stress_in;
let mut need_space = word_sep;
let mut prev_phcode: u8 = 0; const PH_VOICED_FLAG: u32 = 1 << 4;
for (idx, &code) in phoneme_bytes.iter().enumerate() {
if code == 0 { break; }
match code {
PHON_STRESS_P | PHON_STRESS_P2 | PHON_STRESS_TONIC => {
stress = PendingStress::Primary;
continue;
}
PHON_STRESS_2 | PHON_STRESS_3 => {
stress = PendingStress::Secondary;
continue;
}
PHON_STRESS_U | PHON_STRESS_D | PHON_STRESS_PREV => {
stress = PendingStress::None;
continue;
}
_ => {}
}
let is_tone = is_tone_phoneme(code, phdata);
if is_pause_code(code) && !is_tone {
if code == 15 { need_space = true;
stress = PendingStress::None;
}
continue;
}
let code = if use_en_overrides {
code
} else {
let is_primary = stress == PendingStress::Primary;
phdata.resolve_stressed_phoneme(code, is_primary)
};
if let Some(ph) = phdata.get(code) {
let is_vowel = ph.typ == 2; let is_stress_type = ph.typ == 1;
if is_stress_type {
if ph.program == 0 {
if ph.std_length <= 4 {
match ph.std_length {
4 => { stress = PendingStress::Primary; }
2 | 3 => { stress = PendingStress::Secondary; }
_ => {}
}
}
continue;
}
if need_space {
out.push(' ');
need_space = false;
}
let ipa = phdata
.phoneme_ipa_string(ph.program)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| crate::translate::ipa_table::phoneme_ipa_lang(
code, ph.mnemonic, false, use_en_overrides,
));
let ipa = if ipa.is_empty() { ph.mnemonic_display() } else { ipa };
out.push_str(&ipa);
prev_phcode = code;
continue;
}
if liaison.resolve {
let mnemonic = ph.mnemonic;
let b0 = (mnemonic & 0xff) as u8;
let b1 = ((mnemonic >> 8) & 0xff) as u8;
let b2 = ((mnemonic >> 16) & 0xff) as u8;
let level = ((b1 == b'2' || b1 == b'3') && b2 == 0 && !is_vowel)
.then(|| b1 - b'0');
if let Some(level) = level {
let next_is_vowel = phoneme_bytes[idx + 1..]
.iter()
.take_while(|&&c| c != 0)
.find(|&&c| !liaison_skip(c))
.and_then(|&c| phdata.get(c))
.map(|ph| ph.typ == 2 );
match next_is_vowel {
Some(true) => {}
Some(false) => continue,
None if liaison.next_starts_vowel => {}
None if level == 3 && liaison.next_is_pause => {
let citation = match b0 {
b'z' => "s",
b't' => "t",
_ => "",
};
if !citation.is_empty() {
if need_space {
out.push(' ');
need_space = false;
}
out.push_str(citation);
prev_phcode = code;
}
continue;
}
None => continue, }
}
}
if need_space {
out.push(' ');
need_space = false;
}
let _word_final = phoneme_bytes[idx+1..].iter()
.all(|&c| c == 0 || c <= 8 || c == 15);
let this_stress_level: u8 = if is_vowel {
match stress {
PendingStress::Primary => 4,
PendingStress::Secondary => 3,
PendingStress::None => 1,
}
} else {
1
};
if is_vowel {
match stress {
PendingStress::Primary => { out.push_str(IPA_STRESS_PRIMARY); }
PendingStress::Secondary => { out.push_str(IPA_STRESS_SECONDARY); }
PendingStress::None => {}
}
stress = PendingStress::None;
}
let b1 = ((ph.mnemonic >> 8) & 0xff) as u8;
{
if let Some(changed) = resolve_virtual_phoneme(
code, idx, phoneme_bytes, prev_phcode, phdata, liaison.next_phoneme,
this_stress_level, liaison.translation_given,
) {
if changed == 1 {
prev_phcode = code;
continue;
}
if let Some(changed_ph) = phdata.get(changed) {
let ipa = phdata
.phoneme_ipa_string(changed_ph.program)
.unwrap_or_else(|| crate::translate::ipa_table::phoneme_ipa_lang(
changed,
changed_ph.mnemonic,
changed_ph.typ == 2,
use_en_overrides,
));
if !ipa.is_empty() {
out.push_str(&ipa);
prev_phcode = changed;
continue;
}
}
}
let b0 = if b1 == b'#' { (ph.mnemonic & 0xff) as u8 } else { 0 };
let prev_voiced = if let Some(prev_ph) = phdata.get(prev_phcode) {
prev_ph.typ == 2 ||
prev_ph.typ == 3 ||
(prev_ph.phflags & PH_VOICED_FLAG) != 0
} else { false };
let ipa_char = if b0 == b'd' {
if prev_voiced { "d" } else { "t" }
} else if b0 == b'z' {
if prev_voiced { "z" } else { "s" }
} else {
""
};
if !ipa_char.is_empty() {
out.push_str(ipa_char);
prev_phcode = code;
continue;
}
}
let ipa = if let Some(ipa_str) = phdata.phoneme_ipa_string(ph.program) {
ipa_str
} else if let Some(ipa) = use_en_overrides
.then(|| en_ipa_override(code, phdata.active_table_name()))
.flatten()
{
ipa.to_string()
} else {
phoneme_ipa_lang(code, ph.mnemonic, is_vowel, false)
};
out.push_str(&ipa);
let effects = phoneme_program_effects(
code, idx, phoneme_bytes, prev_phcode, phdata, liaison.next_phoneme,
this_stress_level, liaison.translation_given,
);
let mut appended: Option<u8> = effects.append_phoneme;
if appended.is_none() {
if let Some(c) = effects.append_if_next_vowel {
let next_real = phoneme_bytes[idx + 1..]
.iter()
.copied()
.take_while(|&b| b != 0)
.find(|&b| !liaison_skip(b))
.unwrap_or(liaison.next_phoneme);
if matches!(phdata.get(next_real), Some(n) if n.typ == 2 ) {
appended = Some(c);
}
}
}
if let Some(c) = appended {
if let Some(extra) = phdata.get(c).and_then(|aph| phdata.phoneme_ipa_string(aph.program)) {
out.push_str(&extra);
}
}
prev_phcode = code;
}
}
(out, stress)
}
pub struct WordResult {
pub phonemes: Vec<u8>,
pub dict_flags: u32,
pub found_in_list: bool,
}
fn append_raw_phonemes(dst: &mut Vec<u8>, src: &[u8]) {
for &b in src {
if b == 0 {
break;
}
dst.push(b);
}
}
fn combine_rules_result(result: &crate::dictionary::rules::RulesResult) -> Vec<u8> {
let mut combined = Vec::new();
append_raw_phonemes(&mut combined, &result.phonemes);
append_raw_phonemes(&mut combined, &result.end_phonemes);
combined
}
fn english_suffix_needs_e(stem: &str, dict: &Dictionary) -> bool {
const ADD_E_EXCEPTIONS: &[&str] = &["ion"];
const ADD_E_ADDITIONS: &[&str] = &["c", "rs", "ir", "ur", "ath", "ns", "u", "spong", "rang", "larg"];
let chars: Vec<char> = stem.chars().collect();
if chars.len() < 2 {
return false;
}
let penultimate = chars[chars.len() - 2] as u32;
let last = chars[chars.len() - 1] as u32;
if is_letter_wc(&dict.letter_bits, penultimate, dict.letter_bits_offset, LETTERGP_VOWEL2)
&& is_letter_wc(&dict.letter_bits, last, dict.letter_bits_offset, LETTERGP_B)
{
return !ADD_E_EXCEPTIONS.iter().any(|suffix| stem.ends_with(suffix));
}
ADD_E_ADDITIONS.iter().any(|suffix| stem.ends_with(suffix))
}
fn remove_standard_prefix(word: &str, end_type: u32) -> Option<(String, u32)> {
if end_type & SUFX_P == 0 {
return None;
}
let n_chars = (end_type & 0x3f) as usize;
if n_chars == 0 {
return None;
}
let cut_end = word
.char_indices()
.nth(n_chars.saturating_sub(1))
.map(|(idx, c)| idx + c.len_utf8());
let Some(start_after) = cut_end else {
return None;
};
if start_after > word.len() {
return None;
}
let stem = word[start_after..].to_string();
if stem.is_empty() {
return None;
}
Some((stem, FLAG_PREFIX_REMOVED))
}
fn remove_standard_suffix(word: &str, end_type: u32, dict: &Dictionary) -> Option<(String, u32, u32)> {
let suffix_len_chars = (end_type & 0x3f) as usize;
if suffix_len_chars == 0 {
return None;
}
let mut chars: Vec<char> = word.chars().collect();
if suffix_len_chars > chars.len() {
return None;
}
let suffix_start = chars.len() - suffix_len_chars;
let ending: String = chars[suffix_start..].iter().collect();
chars.truncate(suffix_start);
if (end_type & SUFX_I) != 0 && chars.last() == Some(&'i') {
*chars.last_mut().unwrap() = 'y';
}
let mut stem: String = chars.iter().collect();
let mut end_flags = (end_type & 0xfff0) | FLAG_SUFX;
if (end_type & SUFX_E) != 0 && dict.lang == "en" && english_suffix_needs_e(&stem, dict) {
stem.push('e');
end_flags |= FLAG_SUFX_E_ADDED;
}
if ending == "s" || ending == "es" {
end_flags |= FLAG_SUFX_S;
}
if ending.starts_with('\'') {
end_flags &= !FLAG_SUFX;
}
let mut stem_word_flags = 0;
if (end_flags & FLAG_SUFX) != 0 {
stem_word_flags |= FLAG_SUFFIX_REMOVED;
}
if (end_type & SUFX_A) != 0 {
stem_word_flags |= FLAG_SUFFIX_VOWEL;
}
Some((stem, end_flags, stem_word_flags))
}
fn lookup_num_phonemes(dict: &Dictionary, key: &str) -> Vec<u8> {
let ctx = LookupCtx {
lookup_symbol: true,
dict_condition: dict.dict_condition,
..Default::default()
};
if let Some(r) = lookup(dict, key, &ctx) {
if !r.phonemes.is_empty() {
return r.phonemes;
}
}
Vec::new()
}
const PHON_END_WORD: u8 = 15;
#[derive(Debug, Clone, Default)]
struct Pronunciation {
bytes: Vec<u8>,
}
impl Pronunciation {
fn push_lookup_word(&mut self, src: &[u8]) {
self.start_word();
self.bytes.extend_from_slice(trim_lookup(src));
}
fn drop_final_phoneme(&mut self) {
if matches!(self.bytes.last(), Some(&b) if b > 8 && b != PHON_END_WORD) {
self.bytes.pop();
}
}
fn append_lookup_suffix(&mut self, src: &[u8]) {
self.bytes.extend_from_slice(trim_lookup(src));
}
fn push_pronunciation(&mut self, other: &Pronunciation) {
let len = other.trimmed_len();
if len == 0 {
return;
}
self.start_word();
self.bytes.extend_from_slice(&other.bytes[..len]);
}
fn finish(mut self) -> Vec<u8> {
if self.bytes.last().copied() != Some(PHON_END_WORD) {
self.bytes.push(PHON_END_WORD);
}
self.bytes.push(0);
self.bytes
}
fn trimmed_len(&self) -> usize {
self.bytes
.iter()
.rposition(|&b| b != PHON_END_WORD)
.map_or(0, |idx| idx + 1)
}
fn start_word(&mut self) {
if !self.bytes.is_empty() && self.bytes.last().copied() != Some(PHON_END_WORD) {
self.bytes.push(PHON_END_WORD);
}
}
}
fn trim_lookup(src: &[u8]) -> &[u8] {
let len = src.iter().position(|&b| b == 0).unwrap_or(src.len());
&src[..len]
}
fn num_key(raw: impl std::fmt::Display) -> String {
format!("_{raw}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ScaleGroup {
value: u32,
scale: Option<u8>,
}
const MAX_SCALE_GROUP: u32 = 6;
fn split_scale_groups(value: u64, dict: &Dictionary) -> Vec<ScaleGroup> {
let mut groups = Vec::with_capacity(MAX_SCALE_GROUP as usize + 1);
for scale in (0..=MAX_SCALE_GROUP).rev() {
let divisor = 1_000u64.pow(scale);
groups.push(ScaleGroup {
value: ((value / divisor) % 1_000) as u32,
scale: if scale == 0 { None } else { Some(scale as u8) },
});
}
for i in 0..groups.len() {
let Some(scale) = groups[i].scale else { continue };
let val = groups[i].value;
if val == 0 {
continue;
}
let has_word = !lookup_num_phonemes(dict, &format!("_0M{scale}")).is_empty()
|| !lookup_num_phonemes(dict, &format!("_1M{scale}")).is_empty();
if !has_word && i + 1 < groups.len() {
groups[i + 1].value = groups[i + 1].value.saturating_add(val.saturating_mul(1000));
groups[i].value = 0;
}
}
groups
}
fn append_scale_word(
dst: &mut Pronunciation,
group_value: u32,
scale: u8,
dict: &Dictionary,
grammar: &NumberGrammar,
exact: bool,
) {
if grammar.thousands_variant != SlavicThousands::None {
let count = group_value;
let dedicated = lookup_num_phonemes(dict, &format!("_{count}M{scale}"));
if !dedicated.is_empty() {
dst.push_lookup_word(&dedicated);
return;
}
let teen = (11..=19).contains(&(count % 100));
let d = count % 10;
let prefix = match grammar.thousands_variant {
SlavicThousands::None => unreachable!(),
SlavicThousands::Ru if !teen && d == 1 => "1MA",
SlavicThousands::Ru if !teen && (2..=4).contains(&d) => "0MA",
SlavicThousands::Cs | SlavicThousands::Sk if (2..=4).contains(&count) => "0MA",
SlavicThousands::Pl if !teen && (2..=4).contains(&d) => "0MA",
SlavicThousands::Lt if teen || d == 0 => "0MB",
SlavicThousands::Lt if d == 1 => "0MA",
SlavicThousands::Hr if !teen && d == 1 => "1M",
SlavicThousands::Hr if !teen && (2..=4).contains(&d) => "0MA",
_ => "0M",
};
let mut scale_word = lookup_num_phonemes(dict, &format!("_{prefix}{scale}"));
if scale_word.is_empty() {
scale_word = lookup_num_phonemes(dict, &format!("_0M{scale}"));
}
let feminine = scale == 1
&& match grammar.thousands_variant {
SlavicThousands::Ru | SlavicThousands::Hr => true,
SlavicThousands::Sk => count < 10,
_ => false,
};
dst.push_pronunciation(&num3_phonemes_g(dict, count, false, grammar, feminine));
dst.push_lookup_word(&scale_word);
return;
}
let scale_word = {
let variant = if exact {
lookup_num_phonemes(dict, &format!("_0M{scale}x"))
} else {
Vec::new()
};
if !variant.is_empty() {
variant
} else {
lookup_num_phonemes(dict, &format!("_0M{scale}"))
}
};
let singular_key = format!("_1M{scale}");
if scale == 1 && group_value == 1 && grammar.thousands.omit_one_prefix {
dst.push_lookup_word(&scale_word);
return;
}
if group_value == 1 {
let singular = lookup_num_phonemes(dict, &singular_key);
if !singular.is_empty() {
dst.push_lookup_word(&singular);
return;
}
}
dst.push_pronunciation(&num3_phonemes_before_scale(dict, group_value, grammar));
dst.push_lookup_word(&scale_word);
}
fn append_cardinal_group(
dst: &mut Pronunciation,
group: ScaleGroup,
dict: &Dictionary,
grammar: &NumberGrammar,
exact: bool,
) {
if group.value == 0 {
return;
}
if let Some(scale) = group.scale {
append_scale_word(dst, group.value, scale, dict, grammar, exact);
} else {
dst.push_pronunciation(&num3_phonemes(dict, group.value, false, grammar));
}
}
fn append_ordinal_scale(
dst: &mut Pronunciation,
group_value: u32,
scale: u8,
dict: &Dictionary,
grammar: &NumberGrammar,
) -> bool {
let singular_ord_key = format!("_1M{scale}o");
if group_value == 1 {
let singular_ord = lookup_num_phonemes(dict, &singular_ord_key);
if !singular_ord.is_empty() {
dst.push_lookup_word(&singular_ord);
return true;
}
}
let ord_key = format!("_0M{scale}o");
let ord_scale = lookup_num_phonemes(dict, &ord_key);
if !ord_scale.is_empty() {
if !(scale == 1 && group_value == 1 && grammar.thousands.omit_one_prefix) {
dst.push_pronunciation(&num3_phonemes_before_scale(dict, group_value, grammar));
}
dst.push_lookup_word(&ord_scale);
return true;
}
append_scale_word(dst, group_value, scale, dict, grammar, false);
false
}
fn unit_ph(dict: &Dictionary, n: u32, feminine: bool) -> Vec<u8> {
if feminine && (n == 1 || n == 2) {
let f = lookup_num_phonemes(dict, &format!("_{n}f"));
if !f.is_empty() {
return f;
}
}
lookup_num_phonemes(dict, &num_key(n))
}
fn unit_before_scale(dict: &Dictionary, n: u32) -> Vec<u8> {
let a = lookup_num_phonemes(dict, &format!("_{n}a"));
if a.is_empty() { lookup_num_phonemes(dict, &num_key(n)) } else { a }
}
fn num3_phonemes(
dict: &Dictionary,
value: u32,
suppress_null: bool,
grammar: &NumberGrammar,
) -> Pronunciation {
num3_phonemes_g(dict, value, suppress_null, grammar, false)
}
fn num3_phonemes_before_scale(
dict: &Dictionary,
value: u32,
grammar: &NumberGrammar,
) -> Pronunciation {
if value < 20 {
let combining = unit_before_scale(dict, value);
if !combining.is_empty() && combining != lookup_num_phonemes(dict, &num_key(value)) {
let mut p = Pronunciation::default();
p.push_lookup_word(&combining);
return p;
}
}
num3_phonemes_g(dict, value, false, grammar, false)
}
fn num3_phonemes_g(
dict: &Dictionary,
value: u32,
suppress_null: bool,
grammar: &NumberGrammar,
feminine: bool,
) -> Pronunciation {
let hundreds = value / 100;
let tensunits = value % 100;
let mut hundreds_part = Pronunciation::default();
let mut tens_part = Pronunciation::default();
let mut suppress_null = suppress_null;
if hundreds > 0 {
let skip_dedicated = hundreds == 1 && grammar.hundreds.omit_one_hundred_word;
let exact = if tensunits == 0 && !skip_dedicated {
lookup_num_phonemes(dict, &format!("_{}C0", hundreds))
} else {
Vec::new()
};
let compound = if skip_dedicated {
Vec::new()
} else {
lookup_num_phonemes(dict, &format!("_{}C", hundreds))
};
if !exact.is_empty() {
hundreds_part.push_lookup_word(&exact);
} else if !compound.is_empty() {
hundreds_part.push_lookup_word(&compound);
} else {
if !(hundreds == 1 && (grammar.hundreds.omit_one_prefix || skip_dedicated)) {
hundreds_part.push_lookup_word(&unit_before_scale(dict, hundreds));
}
let bare = if skip_dedicated && tensunits == 0 {
let e = lookup_num_phonemes(dict, "_0C0");
if e.is_empty() { lookup_num_phonemes(dict, "_0C") } else { e }
} else {
lookup_num_phonemes(dict, "_0C")
};
hundreds_part.append_lookup_suffix(&bare);
}
suppress_null = true;
}
if tensunits != 0 || !suppress_null {
if tensunits < 20 {
let whole = unit_ph(dict, tensunits, feminine);
let ten_word = || {
let t = lookup_num_phonemes(dict, "_10");
if t.is_empty() { lookup_num_phonemes(dict, "_1X") } else { t }
};
if !whole.is_empty() {
tens_part.push_lookup_word(&whole);
} else if tensunits == 10 {
let ten = ten_word();
if !ten.is_empty() {
tens_part.push_lookup_word(&ten);
}
} else if tensunits > 10 {
let ten = lookup_num_phonemes(dict, "_1X");
let ten = if ten.is_empty() { lookup_num_phonemes(dict, "_10") } else { ten };
if !ten.is_empty() {
tens_part.push_lookup_word(&ten);
if grammar.tens == TensGrammar::WithConjunction {
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, "_0and"));
}
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, &num_key(tensunits - 10)));
}
}
} else {
let ph_full = lookup_num_phonemes(dict, &num_key(tensunits));
if !ph_full.is_empty() {
tens_part.push_lookup_word(&ph_full);
} else if grammar.vigesimal_70_90 && (70..80).contains(&tensunits) {
tens_part.push_lookup_word(&lookup_num_phonemes(dict, "_6X"));
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, &num_key(tensunits - 60)));
} else if grammar.vigesimal_70_90 && (90..100).contains(&tensunits) {
tens_part.push_lookup_word(&lookup_num_phonemes(dict, "_8X"));
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, &num_key(tensunits - 80)));
} else {
let tens = tensunits / 10;
let units = tensunits % 10;
match grammar.tens {
TensGrammar::UnitsThenConjunction if units != 0 => {
let unit = match (units, grammar.combining_one.as_deref()) {
(1, Some(one)) => lookup_num_phonemes(dict, one),
_ => lookup_num_phonemes(dict, &num_key(units)),
};
tens_part.push_lookup_word(&unit);
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, "_0and"));
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, &format!("_{tens}X")));
}
TensGrammar::UnitsThenConjunction => {
tens_part.push_lookup_word(&lookup_num_phonemes(dict, &format!("_{tens}X")));
}
TensGrammar::WithConjunction => {
tens_part.push_lookup_word(&lookup_num_phonemes(dict, &format!("_{tens}X")));
if units != 0 {
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, "_0and"));
tens_part.append_lookup_suffix(&lookup_num_phonemes(dict, &num_key(units)));
}
}
TensGrammar::Standard => {
tens_part.push_lookup_word(&lookup_num_phonemes(dict, &format!("_{tens}X")));
if units != 0 {
if grammar.elide_tens_vowel && (units == 1 || units == 8) {
tens_part.drop_final_phoneme();
}
tens_part.append_lookup_suffix(&unit_ph(dict, units, feminine));
}
}
}
}
}
}
if hundreds > 0 && tensunits > 0 && grammar.hundreds.use_conjunction_with_remainder {
hundreds_part.append_lookup_suffix(&lookup_num_phonemes(dict, "_0and"));
} else if hundreds > 0 && tensunits > 0 && grammar.hundreds.conjunction_before_simple_remainder {
let simple = tensunits % 10 == 0
|| !lookup_num_phonemes(dict, &num_key(tensunits)).is_empty();
if simple {
hundreds_part.append_lookup_suffix(&lookup_num_phonemes(dict, "_0and"));
}
}
let mut result = Pronunciation::default();
result.push_pronunciation(&hundreds_part);
result.push_pronunciation(&tens_part);
result
}
fn number_token_to_phonemes(
token: &NumberToken,
dict: &Dictionary,
grammar: &NumberGrammar,
) -> Option<Vec<u8>> {
match token {
NumberToken::Cardinal(digits) => Some(cardinal_pronunciation(digits, dict, grammar)?.finish()),
NumberToken::Decimal { integer, fractional } => {
let mut pronunciation = if integer.is_empty() {
Pronunciation::default()
} else {
cardinal_pronunciation(integer, dict, grammar)?
};
let decimal_point = lookup_num_phonemes(dict, "_dpt");
if !decimal_point.is_empty() {
pronunciation.push_lookup_word(&decimal_point);
}
if grammar.fraction_suffix
&& push_fraction(&mut pronunciation, fractional, dict, grammar)
{
return Some(pronunciation.finish());
}
if grammar.fraction_digits_as_number > 0
&& push_fraction_as_number(&mut pronunciation, fractional, dict, grammar)
{
return Some(pronunciation.finish());
}
for digit in fractional.bytes() {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &num_key(digit - b'0')));
}
Some(pronunciation.finish())
}
NumberToken::Ordinal(_) => None,
}
}
fn push_fraction(
pronunciation: &mut Pronunciation,
fractional: &str,
dict: &Dictionary,
grammar: &NumberGrammar,
) -> bool {
let count = fractional.len();
if count == 0 || !fractional.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let Ok(value) = fractional.parse::<u64>() else { return false };
let feminine_units = grammar.fraction_feminine && matches!(value % 10, 1 | 2)
&& !matches!(value % 100, 11 | 12);
let singular = grammar.fraction_feminine && value % 10 == 1 && value % 100 != 11;
let suffix = {
let plural = lookup_num_phonemes(dict, &format!("_0Z{count}"));
let chosen = if singular {
let s = lookup_num_phonemes(dict, &format!("_0Z{count}s"));
if s.is_empty() { plural } else { s }
} else {
plural
};
if chosen.is_empty() {
return false;
}
chosen
};
if feminine_units {
let tens = value - (value % 10);
if tens > 0 {
let Some(p) = cardinal_pronunciation(&tens.to_string(), dict, grammar) else {
return false;
};
pronunciation.push_lookup_word(&p.finish());
}
let fem = lookup_num_phonemes(dict, if value % 10 == 1 { "_1f" } else { "_2f" });
if fem.is_empty() {
return false;
}
pronunciation.push_lookup_word(&fem);
} else {
let Some(p) = cardinal_pronunciation(&value.to_string(), dict, grammar) else {
return false;
};
pronunciation.push_lookup_word(&p.finish());
}
pronunciation.push_lookup_word(&suffix);
true
}
fn push_fraction_as_number(
pronunciation: &mut Pronunciation,
fractional: &str,
dict: &Dictionary,
grammar: &NumberGrammar,
) -> bool {
if fractional.is_empty() || !fractional.bytes().all(|b| b.is_ascii_digit()) {
return false;
}
let zeros = fractional.bytes().take_while(|&b| b == b'0').count();
let rest = &fractional[zeros..];
if rest.is_empty() || rest.len() > grammar.fraction_digits_as_number as usize {
return false;
}
let Some(number) = cardinal_pronunciation(rest, dict, grammar) else {
return false;
};
for _ in 0..zeros {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &num_key(0)));
}
pronunciation.push_lookup_word(&number.finish());
true
}
fn cardinal_pronunciation(
digits: &str,
dict: &Dictionary,
grammar: &NumberGrammar,
) -> Option<Pronunciation> {
if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
if digits.len() > 1 && digits.starts_with('0') {
let mut result = Pronunciation::default();
if digits.len() > 3 {
for d in digits.bytes() {
result.push_lookup_word(&lookup_num_phonemes(dict, &num_key(d - b'0')));
}
return Some(result);
}
let n_leading = digits
.bytes()
.take(digits.len() - 1)
.take_while(|&b| b == b'0')
.count();
for _ in 0..n_leading {
result.push_lookup_word(&lookup_num_phonemes(dict, "_0"));
}
let value: u64 = digits.parse().ok()?;
if value == 0 {
result.push_lookup_word(&lookup_num_phonemes(dict, "_0"));
} else {
for group in split_scale_groups(value, dict) {
let exact = scale_group_is_exact(value, group.scale);
append_cardinal_group(&mut result, group, dict, grammar, exact);
}
}
return Some(result);
}
let value: u64 = digits.parse().ok()?;
if value == 0 {
let mut pronunciation = Pronunciation::default();
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, "_0"));
return Some(pronunciation);
}
let is_year_form = (1100..=1999).contains(&value) && value % 100 == 0;
if is_year_form {
let mut pronunciation = num3_phonemes(dict, (value / 100) as u32, false, grammar);
pronunciation.append_lookup_suffix(&lookup_num_phonemes(dict, "_0C"));
return Some(pronunciation);
}
let mut result = Pronunciation::default();
for group in split_scale_groups(value, dict) {
let exact = scale_group_is_exact(value, group.scale);
append_cardinal_group(&mut result, group, dict, grammar, exact);
}
Some(result)
}
fn scale_group_is_exact(value: u64, scale: Option<u8>) -> bool {
match scale {
Some(s) => value % 1000u64.pow(s as u32) == 0,
None => true,
}
}
fn ordinal_sub_thousand_pronunciation(
value: u32,
dict: &Dictionary,
grammar: &NumberGrammar,
suffix_ph: &[u8],
) -> (Pronunciation, bool) {
let hundreds = value / 100;
let tensunits = value % 100;
let units = value % 10;
let tens = tensunits / 10;
let mut pronunciation = Pronunciation::default();
let mut found_ordinal = false;
if value == 0 {
pronunciation.push_pronunciation(&num3_phonemes(dict, 0, false, grammar));
return (pronunciation, false);
}
if hundreds > 0 {
if tensunits == 0 {
let ord_hundreds = lookup_num_phonemes(dict, "_0Co");
if !ord_hundreds.is_empty() {
if hundreds > 1 {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &num_key(hundreds)));
}
pronunciation.push_lookup_word(&ord_hundreds);
found_ordinal = true;
} else {
pronunciation.push_pronunciation(&num3_phonemes(dict, hundreds * 100, false, grammar));
}
} else {
pronunciation.push_pronunciation(&num3_phonemes(dict, hundreds * 100, false, grammar));
}
}
let full_ord = lookup_num_phonemes(dict, &format!("_{tensunits}o"));
if !full_ord.is_empty() {
pronunciation.push_lookup_word(&full_ord);
found_ordinal = true;
} else if tens >= 2 && units > 0 && grammar.ordinals.compound_cardinal_suffix {
pronunciation.push_pronunciation(&num3_phonemes(dict, tensunits, false, grammar));
let ord_suffix: Vec<u8> = if !suffix_ph.is_empty() {
suffix_ph.to_vec()
} else {
lookup_num_phonemes(dict, "_ord")
};
if !ord_suffix.is_empty() {
pronunciation.append_lookup_suffix(&ord_suffix);
found_ordinal = true;
}
} else if tens >= 2 && units > 0 {
let tens_ord = lookup_num_phonemes(dict, &format!("_{tens}Xo"));
if !tens_ord.is_empty() {
pronunciation.push_lookup_word(&tens_ord);
pronunciation.append_lookup_suffix(suffix_ph);
} else {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &format!("_{tens}X")));
}
let units_ord = lookup_num_phonemes(dict, &format!("_{units}o"));
if !units_ord.is_empty() {
pronunciation.push_lookup_word(&units_ord);
found_ordinal = true;
} else {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &num_key(units)));
}
} else if tens >= 2 {
pronunciation.push_lookup_word(&lookup_num_phonemes(dict, &format!("_{tens}X")));
} else if tensunits > 0 {
pronunciation.push_pronunciation(&num3_phonemes(dict, tensunits, false, grammar));
}
(pronunciation, found_ordinal)
}
fn try_ordinal_number(
ordinal: &OrdinalNumber,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
grammar: &NumberGrammar,
) -> Option<WordResult> {
let suffix = match &ordinal.marker {
OrdinalMarker::Suffix(suffix) => suffix.as_str(),
OrdinalMarker::Dot => ".",
};
let suffix_ph = lookup_num_phonemes(dict, &format!("_#{suffix}"));
let is_ordinal = !suffix_ph.is_empty()
|| grammar.ordinals.indicator.as_deref() == Some(suffix)
|| matches!(ordinal.marker, OrdinalMarker::Dot) && grammar.ordinals.dot_marks_ordinal;
if !is_ordinal {
return None;
}
let value: u64 = ordinal.digits.parse().ok()?;
let mut pronunciation = Pronunciation::default();
let groups = split_scale_groups(value, dict);
let last_nonzero = groups
.iter()
.rposition(|group| group.value != 0)
.unwrap_or(groups.len().saturating_sub(1));
for &group in &groups[..last_nonzero] {
append_cardinal_group(&mut pronunciation, group, dict, grammar, false);
}
let final_group = groups[last_nonzero];
let found_ordinal = if let Some(scale) = final_group.scale {
append_ordinal_scale(
&mut pronunciation,
final_group.value,
scale,
dict,
grammar,
)
} else {
let (remainder_ordinal, found) =
ordinal_sub_thousand_pronunciation(final_group.value, dict, grammar, &suffix_ph);
pronunciation.push_pronunciation(&remainder_ordinal);
found
};
if found_ordinal {
pronunciation.append_lookup_suffix(&suffix_ph);
} else {
let ord_ph = lookup_num_phonemes(dict, "_ord");
if !ord_ph.is_empty() {
pronunciation.append_lookup_suffix(&ord_ph);
} else {
pronunciation.append_lookup_suffix(&suffix_ph);
}
}
let mut phonemes = pronunciation.finish();
set_word_stress(&mut phonemes, phdata, stress_opts, Some(0), -1, 0);
Some(WordResult { phonemes, dict_flags: 0, found_in_list: false })
}
fn translate_number_token(
token: &NumberToken,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
grammar: &NumberGrammar,
) -> Option<WordResult> {
match token {
NumberToken::Ordinal(ordinal) => try_ordinal_number(ordinal, dict, phdata, stress_opts, grammar)
.map(|wr| WordResult { found_in_list: true, ..wr }),
_ => {
let mut phonemes = number_token_to_phonemes(token, dict, grammar)?;
set_word_stress(&mut phonemes, phdata, stress_opts, Some(0), -1, 0);
Some(WordResult { phonemes, dict_flags: 0, found_in_list: true })
}
}
}
#[derive(Clone, Copy, Debug, Default)]
pub struct PosExpect {
pub verb: bool,
pub noun: bool,
pub past: bool,
pub at_clause_end: bool,
pub at_clause_start: bool,
pub first_upper: bool,
pub all_upper: bool,
pub next_word: [u8; 8],
}
fn expect_before(next: Option<&str>) -> PosExpect {
let mut e = PosExpect::default();
if let Some(nw) = next {
let b = nw.as_bytes();
let n = (0..=b.len().min(8)).rev().find(|&k| nw.is_char_boundary(k)).unwrap_or(0);
e.next_word[..n].copy_from_slice(&b[..n]);
}
e
}
fn pos_expect_after(prev: Option<&str>) -> PosExpect {
let Some(p) = prev else { return PosExpect::default() };
const VERB_TRIGGERS: &[&str] = &[
"to", "will", "would", "shall", "should", "can", "could", "may", "might",
"must", "cannot", "don't", "doesn't", "didn't", "let", "lets", "please",
"i", "we", "you", "they", "he", "she", "it",
];
const PAST_TRIGGERS: &[&str] = &["had", "has", "have", "having", "was", "were", "been"];
const NOUN_TRIGGERS: &[&str] = &[
"a", "an", "the", "this", "that", "these", "those", "my", "your", "his",
"her", "its", "our", "their", "no", "some", "any", "each", "every", "another",
];
PosExpect {
verb: VERB_TRIGGERS.contains(&p),
noun: NOUN_TRIGGERS.contains(&p),
past: PAST_TRIGGERS.contains(&p),
..Default::default()
}
}
pub fn word_to_phonemes(
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
lang_opts: &LangOptions,
) -> WordResult {
let r = word_to_phonemes_inner(word, dict, phdata, stress_opts, lang_opts, 0, PosExpect::default());
emoji_parts_fallback(r, word, dict, phdata, stress_opts, lang_opts, PosExpect::default())
}
fn emoji_parts_fallback(
result: WordResult,
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
lang_opts: &LangOptions,
expect: PosExpect,
) -> WordResult {
let spoke_something = result.phonemes.iter().any(|&b| b != 0);
if spoke_something || !word.chars().any(is_skin_tone) {
return result;
}
let base: String = word.chars().filter(|&c| !is_skin_tone(c)).collect();
let mut parts: Vec<String> = Vec::new();
if !base.is_empty() {
parts.push(base);
}
parts.extend(word.chars().filter(|&c| is_skin_tone(c)).map(String::from));
let mut phonemes: Vec<u8> = Vec::new();
let mut dict_flags = 0;
let mut found_in_list = false;
for part in parts {
let r = word_to_phonemes_inner(&part, dict, phdata, stress_opts, lang_opts, 0, expect);
if !r.phonemes.iter().any(|&b| b != 0) {
continue;
}
if !phonemes.is_empty() {
phonemes.push(crate::phoneme::PHON_END_WORD);
}
phonemes.extend(r.phonemes);
dict_flags |= r.dict_flags;
found_in_list |= r.found_in_list;
}
if phonemes.is_empty() {
return result;
}
WordResult { phonemes, dict_flags, found_in_list }
}
pub fn word_to_phonemes_pos(
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
lang_opts: &LangOptions,
expect: PosExpect,
) -> WordResult {
let r = word_to_phonemes_inner(word, dict, phdata, stress_opts, lang_opts, 0, expect);
emoji_parts_fallback(r, word, dict, phdata, stress_opts, lang_opts, expect)
}
fn decompose_hangul(word: &str) -> Option<String> {
const SBASE: u32 = 0xAC00;
const LBASE: u32 = 0x1100;
const VBASE: u32 = 0x1161;
const TBASE: u32 = 0x11A7;
const VCOUNT: u32 = 21;
const TCOUNT: u32 = 28;
let is_hangul = |c: char| (SBASE..=0xD7A3).contains(&(c as u32));
if !word.chars().any(is_hangul) {
return None;
}
let mut out = String::with_capacity(word.len() + 6);
for c in word.chars() {
let cp = c as u32;
if is_hangul(c) {
let s = cp - SBASE;
let (l, v, t) = (s / (VCOUNT * TCOUNT), (s % (VCOUNT * TCOUNT)) / TCOUNT, s % TCOUNT);
if l != 11 {
out.push(char::from_u32(LBASE + l).unwrap());
}
out.push(char::from_u32(VBASE + v).unwrap());
if t != 0 {
out.push(char::from_u32(TBASE + t).unwrap());
}
} else {
out.push(c);
}
}
Some(out)
}
const FLAG_IN_TEXTMODE: u32 = 0x4000_0000;
#[allow(clippy::too_many_arguments)]
fn peel_stacked_suffixes(
word: &str,
result: &crate::dictionary::rules::RulesResult,
dict: &Dictionary,
letter_bits: &[u8; 256],
lang_opts: &LangOptions,
phdata: &PhonemeData,
) -> (String, u32, Vec<u8>) {
const MAX_SUFFIXES: usize = 50;
let mut cur_word = word.to_string();
let mut cur_end_type = result.end_type;
let mut end_phonemes: Vec<u8> = result.end_phonemes.to_vec();
for _ in 0..MAX_SUFFIXES {
if cur_end_type & SUFX_M == 0 {
break;
}
let Some((stem, _, stem_word_flags)) =
remove_standard_suffix(&cur_word, cur_end_type, dict)
else {
break;
};
let mut stem_buf = Vec::with_capacity(stem.len() + 3);
stem_buf.push(b' ');
stem_buf.extend_from_slice(stem.as_bytes());
stem_buf.push(b' ');
stem_buf.push(0);
let (mut vc, mut sc) = (0i32, 0i32);
let stem_rules = translate_rules_phdata(
dict,
&stem_buf,
1,
stem_word_flags,
0,
letter_bits,
lang_opts.dict_condition,
&mut vc,
&mut sc,
Some(phdata),
);
if stem_rules.end_type == 0
|| stem_rules.end_type & SUFX_P != 0
|| stem_rules.suffix_start <= 1
{
break;
}
let mut merged: Vec<u8> = stem_rules.end_phonemes.to_vec();
if let Some(pos) = merged.iter().position(|&b| b == 0) {
merged.truncate(pos);
}
merged.extend_from_slice(&end_phonemes);
end_phonemes = merged;
cur_word = stem;
cur_end_type = stem_rules.end_type;
}
(cur_word, cur_end_type, end_phonemes)
}
fn is_tone_phoneme(code: u8, phdata: &PhonemeData) -> bool {
matches!(phdata.get(code), Some(ph) if ph.typ == 1 && ph.program != 0)
}
fn reorder_tone_phonemes(phonemes: &mut Vec<u8>, phdata: &PhonemeData) {
if !phonemes.iter().take_while(|&&c| c != 0).any(|&c| is_tone_phoneme(c, phdata)) {
return;
}
let is_vowel = |c: u8| matches!(phdata.get(c), Some(ph) if ph.typ == 2 );
let is_break = |c: u8| c == 0 || c == crate::phoneme::PHON_END_WORD;
let mut out: Vec<u8> = Vec::with_capacity(phonemes.len());
let mut last_vowel: Option<usize> = None;
let mut last_vowel_toned = false;
let mut pending_tone: Option<u8> = None;
for &code in phonemes.iter() {
if is_break(code) {
pending_tone = None;
last_vowel = None;
last_vowel_toned = false;
out.push(code);
if code == 0 {
break;
}
continue;
}
if is_tone_phoneme(code, phdata) {
match last_vowel {
Some(v) if !last_vowel_toned => {
out.insert(v + 1, code);
last_vowel_toned = true;
}
Some(_) => {}
None => {
if pending_tone.is_none() {
pending_tone = Some(code);
}
}
}
continue;
}
out.push(code);
if is_vowel(code) {
last_vowel = Some(out.len() - 1);
last_vowel_toned = false;
if let Some(tone) = pending_tone.take() {
out.push(tone);
last_vowel_toned = true;
}
}
}
*phonemes = out;
}
fn word_to_phonemes_inner(
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
lang_opts: &LangOptions,
rule_word_flags: u32,
expect: PosExpect,
) -> WordResult {
let normalized = dict.apply_replacements(word);
let word = normalized.as_deref().unwrap_or(word);
let hangul = decompose_hangul(word);
let word = hangul.as_deref().unwrap_or(word);
let ctx = LookupCtx {
lookup_symbol: false,
at_clause_end: expect.at_clause_end,
is_first_word: expect.at_clause_start,
word_flags: (if expect.first_upper { crate::dictionary::FLAG_FIRST_UPPER } else { 0 })
| (if expect.all_upper { crate::dictionary::FLAG_ALL_UPPER } else { 0 }),
expect_verb: expect.verb,
expect_noun: expect.noun,
expect_past: expect.past,
dict_condition: lang_opts.dict_condition,
..Default::default()
};
let dict_result = lookup(dict, word, &ctx);
const FLAG_FOUND_ATTRIBUTES: u32 = 0x4000_0000;
let dict_flags_from_lookup = dict_result.as_ref()
.filter(|r| r.flags1.0 & (FLAG_FOUND_ATTRIBUTES | 0x8000_0000) != 0)
.map(|r| r.flags1.0)
.unwrap_or(0);
if let Some(ref result) = dict_result {
if result.flags1.found() && !result.phonemes.is_empty() {
let is_textmode = result.flags1.textmode() != lang_opts.reversed_textmode;
if is_textmode && rule_word_flags & FLAG_IN_TEXTMODE == 0 {
let replacement = String::from_utf8_lossy(&result.phonemes);
let replacement = replacement.trim_end_matches('\0');
let mut phonemes: Vec<u8> = Vec::new();
let tokens = tokenize_opts(replacement, &lang_opts.number_grammar);
let mut parts: Vec<String> = Vec::new();
let mut usable = true;
for t in &tokens {
match t {
Token::Word(w) => parts.push(w.clone()),
Token::Number(NumberToken::Cardinal(n)) => parts.push(n.clone()),
Token::Number(NumberToken::Decimal { integer, fractional }) => {
parts.push(format!("{integer}.{fractional}"))
}
Token::Space | Token::WordJoin | Token::ClauseBoundary(_) | Token::Punctuation(_) => {}
_ => usable = false,
}
}
if !usable || parts.is_empty() {
parts = replacement.split_whitespace().map(str::to_string).collect();
}
for part in &parts {
let wr = word_to_phonemes_inner(
part, dict, phdata, stress_opts, lang_opts,
rule_word_flags | FLAG_IN_TEXTMODE, PosExpect::default(),
);
let ph = wr.phonemes.strip_suffix(&[0]).unwrap_or(&wr.phonemes);
if ph.is_empty() {
continue;
}
if !phonemes.is_empty() {
phonemes.push(crate::phoneme::PHON_END_WORD);
}
phonemes.extend_from_slice(ph);
}
if !phonemes.is_empty() {
return WordResult { phonemes, dict_flags: 0, found_in_list: false };
}
}
let dict_flags = result.flags1.0;
if crate::dictionary::rules::trace_enabled() && rule_word_flags == 0 {
crate::dictionary::rules::trace_found(
word,
&result.phonemes,
[result.flags1.0, result.flags2.0],
);
}
if result.phonemes[0] == crate::phoneme::PHON_SWITCH {
return WordResult { phonemes: result.phonemes.clone(), dict_flags, found_in_list: true };
}
let mut phonemes = result.phonemes.clone();
set_word_stress(&mut phonemes, phdata, stress_opts, Some(dict_flags as u32), -1, 0);
if stress_opts.alt_stress_upgrade {
apply_alt_stress_upgrade(&mut phonemes, phdata);
}
if stress_opts.word_final_devoicing {
apply_word_final_devoicing(&mut phonemes, phdata);
}
return WordResult { phonemes, dict_flags, found_in_list: true };
}
}
if rule_word_flags == 0 {
if let Some(token) = NumberToken::parse(word, &lang_opts.number_grammar) {
if let Some(result) =
translate_number_token(&token, dict, phdata, stress_opts, &lang_opts.number_grammar)
{
return result;
}
}
}
if crate::dictionary::rules::trace_enabled() && rule_word_flags == 0 {
crate::dictionary::rules::trace_word(word);
}
let rule_word_flags = rule_word_flags & !FLAG_IN_TEXTMODE;
let letter_bits = &*dict.letter_bits;
let mut vowel_count = 0i32;
let mut stressed_count = 0i32;
let mut word_buf = Vec::with_capacity(word.len() + 12);
word_buf.push(b' ');
word_buf.extend_from_slice(word.as_bytes());
word_buf.push(b' ');
let next = expect.next_word.split(|&b| b == 0).next().unwrap_or(&[]);
if !next.is_empty() {
word_buf.extend_from_slice(next);
word_buf.push(b' ');
}
word_buf.push(0);
let result = translate_rules_phdata(
dict,
&word_buf,
1, rule_word_flags,
0, &letter_bits,
lang_opts.dict_condition,
&mut vowel_count,
&mut stressed_count,
Some(phdata),
);
let translate_whole_word = || {
let (mut vc, mut sc) = (0i32, 0i32);
let r = crate::dictionary::rules::translate_rules_ext(
dict,
&word_buf,
1,
rule_word_flags,
0,
&letter_bits,
lang_opts.dict_condition,
&mut vc,
&mut sc,
Some(phdata),
false,
);
combine_rules_result(&r)
};
let fallback_phonemes = |result: &crate::dictionary::rules::RulesResult| {
if result.end_type & SUFX_P != 0 {
translate_whole_word()
} else {
combine_rules_result(result)
}
};
fn rules_produced_output(r: &crate::dictionary::rules::RulesResult) -> bool {
if r.spellword || r.end_type != 0 {
return true;
}
r.phonemes.iter().any(|&b| b != 0) || r.end_phonemes.iter().any(|&b| b != 0)
}
if rules_produced_output(&result) {
let mut stress_dict_flags = dict_flags_from_lookup;
let mut phonemes = if result.end_type != 0 && (result.end_type & SUFX_P) != 0 {
if let Some((stem, stem_word_flags)) = remove_standard_prefix(word, result.end_type) {
let mut combined = Vec::new();
append_raw_phonemes(&mut combined, &result.end_phonemes);
let stem_wr = word_to_phonemes_inner(
&stem,
dict,
phdata,
stress_opts,
lang_opts,
stem_word_flags,
expect,
);
append_raw_phonemes(&mut combined, &stem_wr.phonemes);
stress_dict_flags = stem_wr.dict_flags;
combined.push(0);
combined
} else {
let mut fallback = fallback_phonemes(&result);
fallback.push(0);
fallback
}
} else if result.end_type != 0 && result.suffix_start > 1 {
let (peeled_word, peeled_end_type, peeled_end_phonemes) =
peel_stacked_suffixes(word, &result, dict, &letter_bits, lang_opts, phdata);
let word = peeled_word.as_str();
let result_end_type = peeled_end_type;
let result_end_phonemes = peeled_end_phonemes;
if let Some((stem, end_flags, stem_word_flags)) =
remove_standard_suffix(word, result_end_type, dict)
{
let stem_lookup = lookup(
dict,
&stem,
&LookupCtx {
lookup_symbol: true,
end_flags,
expect_verb: expect.verb,
expect_noun: expect.noun,
expect_past: expect.past,
dict_condition: lang_opts.dict_condition,
..Default::default()
},
);
let mut combined = Vec::new();
let mut used_stem = false;
let no_retranslate = result_end_type & crate::dictionary::SUFX_Q != 0;
if let Some(stem_lookup) = stem_lookup {
if !stem_lookup.phonemes.is_empty() {
combined.extend_from_slice(&stem_lookup.phonemes);
stress_dict_flags = stem_lookup.flags1.0;
used_stem = true;
}
}
if !used_stem && !no_retranslate {
let mut stem_buf = Vec::with_capacity(stem.len() + 3);
stem_buf.push(b' ');
stem_buf.extend_from_slice(stem.as_bytes());
stem_buf.push(b' ');
stem_buf.push(0);
let mut stem_vc = 0i32;
let mut stem_sc = 0i32;
let stem_rules = translate_rules_phdata(
dict,
&stem_buf,
1,
stem_word_flags,
0,
&letter_bits,
lang_opts.dict_condition,
&mut stem_vc,
&mut stem_sc,
Some(phdata),
);
let stem_phonemes = if stem_rules.end_type & SUFX_P != 0 {
word_to_phonemes_inner(
&stem, dict, phdata, stress_opts, lang_opts, stem_word_flags, expect,
)
.phonemes
} else {
combine_rules_result(&stem_rules)
};
if stem_phonemes.iter().any(|&b| b != 0) {
append_raw_phonemes(&mut combined, &stem_phonemes);
used_stem = true;
}
}
if used_stem {
append_raw_phonemes(&mut combined, &result_end_phonemes);
combined.push(0);
combined
} else {
let mut fallback = fallback_phonemes(&result);
fallback.push(0);
fallback
}
} else {
let mut fallback = fallback_phonemes(&result);
fallback.push(0);
fallback
}
} else if result.end_type != 0 {
let mut combined = fallback_phonemes(&result);
combined.push(0);
combined
} else {
let mut combined = combine_rules_result(&result);
combined.push(0);
combined
};
let flags_for_stress = if stress_dict_flags != 0 {
Some(stress_dict_flags as u32)
} else {
Some(0) };
set_word_stress(&mut phonemes, phdata, stress_opts, flags_for_stress, -1, 0);
if stress_opts.alt_stress_upgrade {
apply_alt_stress_upgrade(&mut phonemes, phdata);
}
if stress_opts.word_final_devoicing {
apply_word_final_devoicing(&mut phonemes, phdata);
}
reorder_tone_phonemes(&mut phonemes, phdata);
return WordResult { phonemes, dict_flags: stress_dict_flags, found_in_list: false };
}
WordResult { phonemes: Vec::new(), dict_flags: dict_flags_from_lookup, found_in_list: false }
}
#[derive(Clone, PartialEq)]
pub(crate) enum TranslateEntryKind {
Word,
ClauseBoundary,
Other,
LangSwitch,
}
pub(crate) struct TranslateEntry {
pub(crate) phonemes: Vec<u8>,
pub(crate) dict_flags: u32,
pub(crate) found_in_list: bool,
pub(crate) kind: TranslateEntryKind,
pub(crate) word_lower: Option<String>,
pub(crate) no_word_gap: bool,
}
pub(crate) fn parse_inline_phonemes(content: &str, phdata: &PhonemeData) -> Vec<u8> {
let bytes = content.as_bytes();
let mut codes = Vec::new();
let mut i = 0;
while i < bytes.len() {
if bytes[i].is_ascii_whitespace() {
if !codes.is_empty() && codes.last() != Some(&crate::phoneme::PHON_END_WORD) {
codes.push(crate::phoneme::PHON_END_WORD);
}
i += 1;
continue;
}
let maxlen = 4.min(bytes.len() - i);
let mut matched = false;
for len in (1..=maxlen).rev() {
let Some(slice) = content.get(i..i + len) else { continue };
let code = phdata.lookup_phoneme(slice);
if code != 0 {
codes.push(code);
i += len;
matched = true;
if code == crate::phoneme::PHON_SWITCH {
let start = i;
while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
codes.push(bytes[i].to_ascii_lowercase());
i += 1;
}
let name_at = codes.len() - (i - start);
if &codes[name_at..] == b"en" {
codes.truncate(name_at);
}
}
break;
}
}
if !matched {
i += 1; }
}
codes
}
pub fn ssml_to_speech_text(text: &str, lang: &str) -> String {
ssml::process_markup(text)
.into_iter()
.map(|s| interpret_segment_text(&s.text, s.interpret, lang))
.collect()
}
fn interpret_segment_text(text: &str, interpret: ssml::SayAs, lang: &str) -> String {
if interpret == ssml::SayAs::Normal || primary_bcp47_subtag(lang) != "en" {
return text.to_string();
}
match interpret {
ssml::SayAs::Ordinal => ordinalize_english(text),
ssml::SayAs::Date => dateize_english(text),
ssml::SayAs::Time => timeize_english(text),
ssml::SayAs::Normal => unreachable!(),
}
}
const MONTHS: [&str; 12] = [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
];
fn dateize_english(text: &str) -> String {
let parts: Vec<&str> = text.trim().split(['-', '/']).collect();
if parts.len() == 3 && parts[0].len() == 4 {
if let (Ok(y), Ok(m), Ok(d)) =
(parts[0].parse::<u32>(), parts[1].parse::<u32>(), parts[2].parse::<u32>())
{
if (1..=12).contains(&m) && (1..=31).contains(&d) {
let ds = d.to_string();
return format!("{} {d}{} {y}", MONTHS[(m - 1) as usize], english_ordinal_suffix(&ds));
}
}
}
text.to_string()
}
fn timeize_english(text: &str) -> String {
let t = text.trim();
if let Some((h, rest)) = t.split_once(':') {
let m = rest.split(':').next().unwrap_or("");
if let (Ok(hh), Ok(mm)) = (h.parse::<u32>(), m.parse::<u32>()) {
if hh < 24 && mm < 60 {
return match mm {
0 => format!("{hh} o'clock"),
1..=9 => format!("{hh} oh {mm}"),
_ => format!("{hh} {mm}"),
};
}
}
}
text.to_string()
}
fn ordinalize_english(text: &str) -> String {
text.split_whitespace()
.map(|tok| {
if !tok.is_empty() && tok.bytes().all(|b| b.is_ascii_digit()) {
format!("{tok}{}", english_ordinal_suffix(tok))
} else {
tok.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn english_ordinal_suffix(digits: &str) -> &'static str {
let last_two = &digits[digits.len().saturating_sub(2)..];
if matches!(last_two, "11" | "12" | "13") {
return "th";
}
match digits.as_bytes().last() {
Some(b'1') => "st",
Some(b'2') => "nd",
Some(b'3') => "rd",
_ => "th",
}
}
fn is_single_letter_word(w: &str) -> bool {
let mut chars = w.chars().filter(|&c| c != '\u{02c8}');
matches!(chars.next(), Some(c) if c.is_alphabetic()) && chars.next().is_none()
}
fn is_spoken_symbol(c: char) -> bool {
matches!(c, '.' | '#' | '$' | '%' | '&' | '*' | '+' | '/' | '=' | '@' | '~'
| '×' | '÷' | '°' | '±' | '−' | '§' | '№'
| '∞' | '√' | '∑' | '≈' | '≠' | '≤' | '≥'
| '℃' | '℉' | '™' | '®' | '©'
| '‰' | '‱')
}
fn is_currency_symbol(c: char) -> bool {
matches!(c, '$' | '€' | '£' | '¥' | '¢')
}
fn minus_precedes_number<I>(chars: &std::iter::Peekable<I>) -> bool
where
I: Iterator<Item = char> + Clone,
{
let mut la = chars.clone();
match la.next() {
Some(d) if d.is_ascii_digit() => true,
Some(cur) if is_currency_symbol(cur) => {
let mut next = la.next();
if next == Some(' ') {
next = la.next();
}
next.map_or(false, |d| d.is_ascii_digit())
}
_ => false,
}
}
fn apply_space_grouping(tokens: &mut Vec<Token>, grammar: &NumberGrammar) {
if !grammar.space_group {
return;
}
let is_digits = |s: &str| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit());
let mut out: Vec<Token> = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Number(NumberToken::Cardinal(first)) = &tokens[i] {
if first.len() <= 3 && is_digits(first) {
let mut merged = first.clone();
let mut j = i + 1;
let mut groups = 0;
while matches!(tokens.get(j), Some(Token::Space | Token::WordJoin)) {
if let Some(Token::Number(NumberToken::Cardinal(g))) = tokens.get(j + 1) {
if g.len() == 3 && is_digits(g) {
merged.push_str(g);
j += 2;
groups += 1;
continue;
}
}
break;
}
let mut fraction: Option<String> = None;
if matches!(tokens.get(j), Some(Token::Space | Token::WordJoin)) {
if let Some(Token::Number(NumberToken::Decimal { integer, fractional })) =
tokens.get(j + 1)
{
if integer.len() == 3 && is_digits(integer) {
merged.push_str(integer);
fraction = Some(fractional.clone());
j += 2;
groups += 1;
}
}
}
if groups >= 1 {
let token = match fraction {
Some(fractional) => {
NumberToken::Decimal { integer: merged, fractional }
}
None => NumberToken::Cardinal(merged),
};
out.push(Token::Number(token));
i = j;
continue;
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum Script {
Latin,
Cyrillic,
Greek,
Arabic,
Hebrew,
Armenian,
Georgian,
Devanagari,
Bengali,
Gurmukhi,
Gujarati,
Oriya,
Tamil,
Telugu,
Kannada,
Malayalam,
Sinhala,
Thai,
Myanmar,
Ethiopic,
Cherokee,
Hangul,
Kana,
Han,
}
fn char_script(c: char) -> Option<Script> {
let cp = c as u32;
Some(match cp {
0x0041..=0x005A | 0x0061..=0x007A => Script::Latin,
0x00C0..=0x00FF if cp != 0x00D7 && cp != 0x00F7 => Script::Latin,
0x0100..=0x024F | 0x1E00..=0x1EFF => Script::Latin, 0x0370..=0x03FF | 0x1F00..=0x1FFF => Script::Greek,
0x0400..=0x052F => Script::Cyrillic,
0x0530..=0x058F => Script::Armenian,
0x0590..=0x05FF => Script::Hebrew,
0x0600..=0x06FF | 0x0750..=0x077F | 0xFB50..=0xFDFF | 0xFE70..=0xFEFF => Script::Arabic,
0x0900..=0x097F => Script::Devanagari,
0x0980..=0x09FF => Script::Bengali,
0x0A00..=0x0A7F => Script::Gurmukhi,
0x0A80..=0x0AFF => Script::Gujarati,
0x0B00..=0x0B7F => Script::Oriya,
0x0B80..=0x0BFF => Script::Tamil,
0x0C00..=0x0C7F => Script::Telugu,
0x0C80..=0x0CFF => Script::Kannada,
0x0D00..=0x0D7F => Script::Malayalam,
0x0D80..=0x0DFF => Script::Sinhala,
0x0E00..=0x0E7F => Script::Thai,
0x1000..=0x109F => Script::Myanmar,
0x10A0..=0x10FF | 0x2D00..=0x2D2F => Script::Georgian,
0x1200..=0x137F => Script::Ethiopic,
0x13A0..=0x13FF | 0xAB70..=0xABBF => Script::Cherokee,
0x1100..=0x11FF | 0x3130..=0x318F | 0xAC00..=0xD7AF => Script::Hangul,
0x3040..=0x30FF | 0x31F0..=0x31FF => Script::Kana,
0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF => Script::Han,
0x20000..=0x2FA1F => Script::Han, _ => return None,
})
}
fn script_uses_words(script: Script) -> bool {
matches!(
script,
Script::Armenian
| Script::Arabic
| Script::Devanagari
| Script::Bengali
| Script::Gurmukhi
| Script::Gujarati
| Script::Tamil
| Script::Kannada
| Script::Malayalam
| Script::Sinhala
| Script::Georgian
| Script::Hangul
)
}
fn script_voice(script: Script) -> &'static str {
match script {
Script::Latin => "en",
Script::Cyrillic => "ru",
Script::Greek => "el",
Script::Arabic => "ar",
Script::Hebrew => "he",
Script::Armenian => "hy",
Script::Georgian => "ka",
Script::Devanagari => "hi",
Script::Bengali => "bn",
Script::Gurmukhi => "pa",
Script::Gujarati => "gu",
Script::Oriya => "or",
Script::Tamil => "ta",
Script::Telugu => "te",
Script::Kannada => "kn",
Script::Malayalam => "ml",
Script::Sinhala => "si",
Script::Thai => "th",
Script::Myanmar => "my",
Script::Ethiopic => "am",
Script::Cherokee => "chr",
Script::Hangul => "ko",
Script::Kana => "ja",
Script::Han => "cmn",
}
}
fn native_scripts(lang: &str) -> &'static [Script] {
match primary_bcp47_subtag(lang) {
"ru" | "uk" | "bg" | "be" | "mk" | "mn" | "kk" | "ky" | "tt" | "ba"
| "cv" | "tg" | "os" | "cu" | "ab" | "nog" => &[Script::Cyrillic],
"sr" => &[Script::Cyrillic, Script::Latin],
"el" | "grc" => &[Script::Greek],
"ar" | "fa" | "ur" | "sd" | "ps" | "ug" => &[Script::Arabic],
"he" => &[Script::Hebrew],
"hy" | "hyw" => &[Script::Armenian],
"ka" => &[Script::Georgian],
"hi" | "mr" | "ne" | "sa" | "kok" | "mai" => &[Script::Devanagari],
"bn" | "as" | "bpy" => &[Script::Bengali],
"pa" => &[Script::Gurmukhi],
"gu" => &[Script::Gujarati],
"or" => &[Script::Oriya],
"ta" => &[Script::Tamil],
"te" => &[Script::Telugu],
"kn" => &[Script::Kannada],
"ml" => &[Script::Malayalam],
"si" => &[Script::Sinhala],
"th" => &[Script::Thai],
"my" | "shn" => &[Script::Myanmar],
"am" | "ti" => &[Script::Ethiopic],
"chr" => &[Script::Cherokee],
"ko" => &[Script::Hangul, Script::Han],
"ja" => &[Script::Kana, Script::Han],
"cmn" | "yue" | "hak" | "zh" => &[Script::Han],
_ => &[Script::Latin],
}
}
fn alt_alphabet_voice(lang: &str, script: Script) -> Option<&'static str> {
match (primary_bcp47_subtag(lang), script) {
("ar", Script::Han) => Some("cmn"),
_ => None,
}
}
fn dict_knows_word(word: &str, dict: &Dictionary) -> bool {
let ctx = LookupCtx { lookup_symbol: true, ..Default::default() };
matches!(lookup(dict, &word.to_lowercase(), &ctx), Some(r) if r.flags1.found())
}
fn foreign_script_voice(word: &str, lang: &str) -> Option<&'static str> {
let script = word.chars().find_map(char_script)?;
if native_scripts(lang).contains(&script) {
return None; }
if let Some(alt) = alt_alphabet_voice(lang, script) {
return Some(alt);
}
script_uses_words(script).then(|| script_voice(script))
}
fn is_unpronounceable(rest: &str, dict: &Dictionary, lang_opts: &LangOptions) -> bool {
if lang_opts.unpronouncable == 1 {
return false;
}
if dict.letter_bits_offset > 0 {
return false;
}
let is_latin_letter = |c: char| c.is_alphabetic() && (c as u32) < 0x250;
let is_vowel = |c: char| {
let base = crate::dictionary::rules::fold_accent(c);
matches!(base, 'a' | 'e' | 'i' | 'o' | 'u' | 'y')
|| crate::dictionary::rules::is_letter_wc(
&dict.letter_bits,
c as u32,
dict.letter_bits_offset,
7, )
};
let Some(first) = rest.chars().next() else { return false };
if !is_latin_letter(first) {
return false;
}
let mut count = 0usize;
let mut vowel_posn = 9usize;
for c in rest.chars() {
if c == ' ' {
break;
}
if !is_latin_letter(c) && c != '\'' {
return false; }
if c == '\'' && count > 1 {
break;
}
count += 1;
if is_vowel(c) {
vowel_posn = count;
break;
}
}
if lang_opts.unpronouncable == 2 {
return vowel_posn == 9;
}
if lang_opts.unpronouncable > 3 && first as u32 == lang_opts.unpronouncable {
vowel_posn = vowel_posn.saturating_sub(1);
}
vowel_posn > lang_opts.max_initial_consonants + 1
}
fn spell_unpronounceable<'a>(
word: &'a str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
options: &LangOptions,
) -> Option<(Vec<u8>, &'a str)> {
if word.chars().count() < 2 {
return None;
}
let mut out: Vec<u8> = Vec::new();
let mut rest = word;
let mut posn = 0usize;
let mut length = 999usize;
while (length < 3 && length > 0)
|| (word.chars().count() > 1 && is_unpronounceable(rest, dict, lang_opts_ref(options)))
{
let Some(c) = rest.chars().next() else { break };
if c == '\'' {
break;
}
let lower = c.to_lowercase().to_string();
let mut wr = word_to_phonemes(&format!("_{lower}"), dict, phdata, stress_opts, options);
if wr.phonemes.iter().all(|&b| b == 0) || wr.dict_flags == 0 {
wr = word_to_phonemes(&lower, dict, phdata, stress_opts, options);
}
if wr.phonemes.iter().all(|&b| b == 0) {
return None;
}
out.extend(wr.phonemes.iter().copied().filter(|&b| b != 0));
rest = &rest[c.len_utf8()..];
posn += 1;
length = rest.chars().count();
if posn > 16 {
break;
}
}
if posn == 0 {
return None;
}
let n_stress = out.iter().filter(|&&b| b == PHON_STRESS_P).count();
let mut count = 0;
for b in out.iter_mut() {
if *b == PHON_STRESS_P {
count += 1;
if count != n_stress && (count % 3 != 0 || count + 1 == n_stress) {
*b = PHON_STRESS_3;
}
}
}
Some((out, rest))
}
fn lang_opts_ref(o: &LangOptions) -> &LangOptions { o }
fn spell_word_letters(
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
options: &LangOptions,
) -> Option<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
for c in word.chars() {
if !c.is_alphanumeric() {
continue;
}
let lower = c.to_lowercase().to_string();
let mut wr = word_to_phonemes(&format!("_{lower}"), dict, phdata, stress_opts, options);
if wr.phonemes.iter().all(|&b| b == 0) || wr.dict_flags == 0 {
wr = word_to_phonemes(&lower, dict, phdata, stress_opts, options);
}
if wr.phonemes.iter().all(|&b| b == 0) {
return None;
}
out.extend(wr.phonemes.iter().copied().filter(|&b| b != 0));
}
if out.is_empty() {
return None;
}
let n_stress = out.iter().filter(|&&b| b == PHON_STRESS_P).count();
let mut count = 0;
for b in out.iter_mut() {
if *b == PHON_STRESS_P {
count += 1;
if count != n_stress && (count % 3 != 0 || count == n_stress - 1) {
*b = PHON_STRESS_3;
}
}
}
Some(out)
}
fn spell_foreign_word(
word: &str,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
options: &LangOptions,
) -> Option<Vec<u8>> {
let mut out: Vec<u8> = Vec::new();
for c in word.chars() {
if matches!(c as u32, 0x0300..=0x036F) {
continue;
}
let wr = word_to_phonemes(
&c.to_lowercase().to_string(), dict, phdata, stress_opts, options,
);
if wr.phonemes.is_empty() || wr.dict_flags == 0 {
return None;
}
if !out.is_empty() && !out.last().is_some_and(|&c| is_pause_code(c)) {
out.push(crate::phoneme::PHON_END_WORD);
}
out.extend(wr.phonemes.iter().copied().filter(|&b| b != 0));
}
(!out.is_empty()).then_some(out)
}
fn to_roman(mut n: u32) -> String {
const TABLE: [(u32, &str); 13] = [
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"), (100, "C"), (90, "XC"),
(50, "L"), (40, "XL"), (10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
];
let mut out = String::new();
for (v, sym) in TABLE {
while n >= v {
out.push_str(sym);
n -= v;
}
}
out
}
fn roman_value(s: &str) -> Option<u32> {
if s.is_empty() {
return None;
}
let digit = |c| match c {
'I' => Some(1),
'V' => Some(5),
'X' => Some(10),
'L' => Some(50),
'C' => Some(100),
'D' => Some(500),
'M' => Some(1000),
_ => None,
};
let mut total: i64 = 0;
let mut highest = 0;
for c in s.chars().rev() {
let v = digit(c)?;
if v < highest {
total -= v as i64;
} else {
total += v as i64;
highest = v;
}
}
let n = u32::try_from(total).ok()?;
(n > 0 && to_roman(n) == s).then_some(n)
}
fn is_roman_context_keyword(lower: &str) -> bool {
matches!(
lower,
"chapter" | "part" | "section" | "book" | "volume" | "vol" | "act" | "scene"
| "appendix" | "article" | "war" | "grade" | "level" | "phase" | "type"
| "class" | "mark" | "figure" | "stage" | "episode" | "series"
)
}
fn apply_roman_numerals(tokens: &mut Vec<Token>, lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
for i in 0..tokens.len() {
let Token::Word(kw) = &tokens[i] else { continue };
if !is_roman_context_keyword(&kw.to_lowercase()) {
continue;
}
let j = if matches!(tokens.get(i + 1), Some(Token::Space | Token::WordJoin)) { i + 2 } else { i + 1 };
if let Some(Token::Word(w)) = tokens.get(j) {
if w.chars().all(|c| c.is_ascii_uppercase()) {
if let Some(n) = roman_value(w) {
tokens[j] = Token::Number(NumberToken::Cardinal(n.to_string()));
}
}
}
}
}
fn is_period_abbreviation(lower: &str) -> bool {
matches!(
lower,
"dr" | "mr" | "mrs" | "ms" | "mx" | "prof" | "rev" | "fr" | "st"
| "gen" | "col" | "sgt" | "capt" | "lt" | "maj" | "cmdr" | "adm"
| "gov" | "sen" | "rep" | "pres" | "hon" | "sir" | "messrs"
| "mme" | "mlle"
)
}
fn suppress_abbreviation_periods(tokens: &mut [Token], lang: &str) {
for i in 0..tokens.len() {
if !matches!(tokens[i], Token::ClauseBoundary('.')) {
continue;
}
let next_word = tokens[i + 1..].iter().find(|t| {
!matches!(t, Token::Space | Token::WordJoin) && !matches!(t, Token::ClauseBoundary(_))
});
let ends_sentence = match next_word {
None => true,
Some(Token::Word(w)) => w.chars().next().is_some_and(|c| !c.is_lowercase()),
Some(_) => true,
};
if !ends_sentence {
tokens[i] = Token::Space;
}
}
for i in 1..tokens.len().saturating_sub(1) {
if matches!(tokens[i], Token::ClauseBoundary(','))
&& matches!(tokens[i - 1], Token::Number(_))
&& matches!(tokens[i + 1], Token::Number(_))
{
tokens[i] = Token::Space;
}
}
if primary_bcp47_subtag(lang) != "en" {
return;
}
for i in 1..tokens.len() {
if matches!(tokens[i], Token::ClauseBoundary('.')) {
if let Token::Word(w) = &tokens[i - 1] {
if is_period_abbreviation(&w.to_lowercase()) {
tokens[i] = Token::Space;
}
}
}
}
}
fn expand_abbreviation(lower: &str) -> Option<&'static str> {
Some(match lower {
"vs" => "versus",
"govt" => "government",
"blvd" => "boulevard",
"sgt" => "sergeant",
"capt" => "captain",
"lt" => "lieutenant",
"rd" => "road",
"inc" => "incorporated",
"prof" => "professor",
"approx" => "approximately",
"misc" => "miscellaneous",
_ => return None,
})
}
fn apply_time_reading(tokens: &mut Vec<Token>, lang: &str) {
let connector = match primary_bcp47_subtag(lang) {
"de" => Some("uhr"),
"fr" => Some("heures"),
"it" => Some("e"),
_ => None,
};
let is_colon = |t: Option<&Token>| matches!(t, Some(Token::Punctuation(':')));
let read_colon = primary_bcp47_subtag(lang) == "en";
let two_digit = |t: Option<&Token>| -> Option<String> {
match t {
Some(Token::Number(NumberToken::Cardinal(s)))
if s.len() == 2 && s.bytes().all(|b| b.is_ascii_digit()) =>
{
Some(s.clone())
}
_ => None,
}
};
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
let hour = match &tokens[i] {
Token::Number(NumberToken::Cardinal(h)) if h.bytes().all(|b| b.is_ascii_digit()) => {
Some(h.clone())
}
_ => None,
};
if let Some(h) = hour {
if is_colon(tokens.get(i + 1)) {
if let Some(minute) = two_digit(tokens.get(i + 2)) {
let h_trim = h.trim_start_matches('0');
let h_norm = if h_trim.is_empty() { "0" } else { h_trim };
let h_val: u32 = h.parse().unwrap_or(u32::MAX);
out.push(Token::Number(NumberToken::Cardinal(h_norm.to_string())));
out.push(Token::Space);
if let Some(word) = connector {
if h_val <= 23 {
out.push(Token::Word(word.to_string()));
out.push(Token::Space);
}
}
out.push(Token::Number(NumberToken::Cardinal(minute)));
let mut j = i + 3;
if is_colon(tokens.get(j)) {
if let Some(sec) = two_digit(tokens.get(j + 1)) {
out.push(Token::Space);
out.push(Token::Number(NumberToken::Cardinal(sec)));
j += 2;
}
}
i = j;
continue;
}
}
}
if matches!(&tokens[i], Token::Punctuation(':')) {
if read_colon {
out.push(Token::Space);
out.push(Token::Word("colon".to_string()));
out.push(Token::Space);
}
i += 1;
continue;
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn apply_dash_reading(tokens: &mut Vec<Token>, lang: &str) {
let dash = match primary_bcp47_subtag(lang) {
"en" => "dash",
"de" => "Strich",
_ => return,
};
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if matches!(&tokens[i], Token::Number(_))
&& matches!(tokens.get(i + 1), Some(Token::Punctuation('-')))
&& matches!(tokens.get(i + 2), Some(Token::Number(_)))
{
out.push(tokens[i].clone()); out.push(Token::Space);
out.push(Token::Word(dash.to_string()));
out.push(Token::Space);
i += 2;
continue;
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn apply_number_abbrev(tokens: &mut Vec<Token>, lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
let is_dot = |t: Option<&Token>| {
matches!(t, Some(Token::Punctuation('.')) | Some(Token::ClauseBoundary('.')))
};
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if matches!(&tokens[i], Token::Word(w) if w == "No") && is_dot(tokens.get(i + 1)) {
let j = if matches!(tokens.get(i + 2), Some(Token::Space | Token::WordJoin)) { i + 3 } else { i + 2 };
if matches!(tokens.get(j), Some(Token::Number(_))) {
out.push(Token::Word("number".to_string()));
out.push(Token::Space);
i = j; continue;
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn apply_dimensions(tokens: &mut [Token], lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
let is_num = |t: Option<&Token>| {
matches!(t, Some(Token::Number(NumberToken::Cardinal(n))) if n != "0")
|| matches!(t, Some(Token::Number(NumberToken::Decimal { .. })))
};
for i in 0..tokens.len() {
if !matches!(&tokens[i], Token::Word(w) if w == "x") {
continue;
}
let prev = match (i, tokens.get(i.wrapping_sub(1))) {
(0, _) => None,
(_, Some(Token::Space | Token::WordJoin)) => tokens.get(i.wrapping_sub(2)),
(_, t) => t,
};
let next = match tokens.get(i + 1) {
Some(Token::Space | Token::WordJoin) => tokens.get(i + 2),
t => t,
};
if is_num(prev) && is_num(next) {
tokens[i] = Token::Word("by".to_string());
}
}
}
fn apply_abbreviations(tokens: &mut [Token], lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
for tok in tokens.iter_mut() {
if let Token::Word(w) = tok {
if let Some(expanded) = expand_abbreviation(&w.to_lowercase()) {
*w = expanded.to_string();
}
}
}
}
fn apply_temperature(tokens: &mut Vec<Token>, _lang: &str) {
for i in 0..tokens.len() {
if !matches!(tokens[i], Token::Punctuation('°')) {
continue;
}
let j = if matches!(tokens.get(i + 1), Some(Token::Space | Token::WordJoin)) { i + 2 } else { i + 1 };
if let Some(Token::Word(w)) = tokens.get(j) {
let scale = match w.as_str() {
"C" => Some("celsius"),
"F" => Some("fahrenheit"),
_ => None,
};
if let Some(s) = scale {
tokens[j] = Token::Word(s.to_string());
}
}
}
}
fn unit_words(abbr: &str, lang: &str) -> Option<(&'static str, &'static str)> {
Some(match primary_bcp47_subtag(lang) {
"en" => match abbr {
"km" => ("kilometre", "kilometres"),
"cm" => ("centimetre", "centimetres"),
"mm" => ("millimetre", "millimetres"),
"kg" => ("kilogram", "kilograms"),
"mg" => ("milligram", "milligrams"),
"ml" => ("millilitre", "millilitres"),
"kb" => ("kilobyte", "kilobytes"),
"mb" => ("megabyte", "megabytes"),
"gb" => ("gigabyte", "gigabytes"),
"tb" => ("terabyte", "terabytes"),
"kw" => ("kilowatt", "kilowatts"),
"hz" => ("hertz", "hertz"),
"khz" => ("kilohertz", "kilohertz"),
"mhz" => ("megahertz", "megahertz"),
"ghz" => ("gigahertz", "gigahertz"),
"mph" => ("mile per hour", "miles per hour"),
_ => return None,
},
"de" => match abbr {
"km" => ("kilometer", "kilometer"),
"cm" => ("zentimeter", "zentimeter"),
"mm" => ("millimeter", "millimeter"),
"kg" => ("kilogramm", "kilogramm"),
"mg" => ("milligramm", "milligramm"),
"ml" => ("milliliter", "milliliter"),
_ => return None,
},
"fr" => match abbr {
"km" => ("kilomètre", "kilomètres"),
"cm" => ("centimètre", "centimètres"),
"mm" => ("millimètre", "millimètres"),
"kg" => ("kilogramme", "kilogrammes"),
"mg" => ("milligramme", "milligrammes"),
"ml" => ("millilitre", "millilitres"),
_ => return None,
},
"es" => match abbr {
"km" => ("kilómetro", "kilómetros"),
"cm" => ("centímetro", "centímetros"),
"mm" => ("milímetro", "milímetros"),
"kg" => ("kilogramo", "kilogramos"),
"mg" => ("miligramo", "miligramos"),
"ml" => ("mililitro", "mililitros"),
_ => return None,
},
"it" => match abbr {
"km" => ("chilometro", "chilometri"),
"cm" => ("centimetro", "centimetri"),
"mm" => ("millimetro", "millimetri"),
"kg" => ("chilogrammo", "chilogrammi"),
"mg" => ("milligrammo", "milligrammi"),
"ml" => ("millilitro", "millilitri"),
_ => return None,
},
_ => return None,
})
}
fn apply_units(tokens: &mut Vec<Token>, lang: &str) {
let after = |i: usize| if matches!(tokens.get(i + 1), Some(Token::Space | Token::WordJoin)) { i + 2 } else { i + 1 };
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Number(n) = &tokens[i] {
let j = after(i);
if let Some(Token::Word(w)) = tokens.get(j) {
if let Some((sing, plur)) = unit_words(&w.to_lowercase(), lang) {
let one = matches!(n, NumberToken::Cardinal(s) if s == "1");
out.push(tokens[i].clone());
out.push(Token::Word(if one { sing } else { plur }.to_string()));
i = j + 1;
continue;
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn apply_decades(tokens: &mut Vec<Token>, lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
let is_s = |t: Option<&Token>| {
matches!(t, Some(Token::Word(w)) if w.trim_start_matches(['\'', '’']).eq_ignore_ascii_case("s"))
};
let apostrophe = |t: Option<&Token>| matches!(t, Some(Token::Punctuation('\'' | '’')));
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Number(NumberToken::Cardinal(digits)) = &tokens[i] {
let consumed = if is_s(tokens.get(i + 1)) {
2
} else if apostrophe(tokens.get(i + 1)) && is_s(tokens.get(i + 2)) {
3
} else {
0
};
if consumed > 0 {
if let Some(decade) = decade_reading(digits) {
out.extend(decade);
i += consumed;
continue;
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn decade_reading(digits: &str) -> Option<Vec<Token>> {
let n: u32 = digits.parse().ok()?;
if n % 10 != 0 {
return None;
}
let tens_plural = |t: u32| -> Option<&'static str> {
Some(match t {
1 => "tens", 2 => "twenties", 3 => "thirties", 4 => "forties",
5 => "fifties", 6 => "sixties", 7 => "seventies", 8 => "eighties", 9 => "nineties",
_ => return None,
})
};
if (10..=90).contains(&n) {
return Some(vec![Token::Word(tens_plural(n / 10)?.to_string())]);
}
if (1100..=1999).contains(&n) {
const TEENS: [&str; 9] = [
"eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen",
];
let (century, dec) = (n / 100, n % 100);
Some(vec![
Token::Word(TEENS[(century - 11) as usize].to_string()),
Token::Word(if dec == 0 { "hundreds".to_string() } else { tens_plural(dec / 10)?.to_string() }),
])
} else {
None
}
}
fn apply_exponents(tokens: &mut Vec<Token>, lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Number(base) = &tokens[i] {
if matches!(tokens.get(i + 1), Some(Token::Punctuation('^'))) {
if let Some(Token::Number(exp)) = tokens.get(i + 2) {
out.push(Token::Number(base.clone()));
for w in ["to", "the", "power", "of"] {
out.push(Token::Word(w.to_string()));
}
out.push(Token::Number(exp.clone()));
i += 3;
continue;
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn fraction_denominator_word(denom: &str, plural: bool) -> Option<&'static str> {
Some(match (denom, plural) {
("2", false) => "half", ("2", true) => "halves",
("3", false) => "third", ("3", true) => "thirds",
("4", false) => "quarter", ("4", true) => "quarters",
("5", false) => "fifth", ("5", true) => "fifths",
("6", false) => "sixth", ("6", true) => "sixths",
("7", false) => "seventh", ("7", true) => "sevenths",
("8", false) => "eighth", ("8", true) => "eighths",
("9", false) => "ninth", ("9", true) => "ninths",
("10", false) => "tenth", ("10", true) => "tenths",
_ => return None,
})
}
fn apply_fractions(tokens: &mut Vec<Token>, lang: &str) {
if primary_bcp47_subtag(lang) != "en" {
return;
}
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Number(NumberToken::Cardinal(num)) = &tokens[i] {
let is_slash = matches!(tokens.get(i + 1), Some(Token::Punctuation('/')));
let not_date = !matches!(tokens.get(i + 3), Some(Token::Punctuation('/')));
if is_slash && not_date {
if let Some(Token::Number(NumberToken::Cardinal(den))) = tokens.get(i + 2) {
if let Some(word) = fraction_denominator_word(den, num != "1") {
let mixed = matches!(out.last(), Some(Token::Space | Token::WordJoin))
&& matches!(out.get(out.len().wrapping_sub(2)), Some(Token::Number(NumberToken::Cardinal(_))));
if mixed {
out.pop(); out.push(Token::Word("and".to_string()));
}
out.push(Token::Number(NumberToken::Cardinal(num.clone())));
out.push(Token::Word(word.to_string()));
i += 3;
continue;
}
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn currency_words(c: char, lang: &str) -> Option<(&'static str, &'static str)> {
Some(match (primary_bcp47_subtag(lang), c) {
("en", '$') => ("dollar", "dollars"),
("en", '€') => ("euro", "euros"),
("en", '£') => ("pound", "pounds"),
("en", '¥') => ("yen", "yen"),
("en", '¢') => ("cent", "cents"),
("de", '€') => ("euro", "euro"),
("de", '$') => ("dollar", "dollar"),
("de", '£') => ("pfund", "pfund"),
("fr", '€') => ("euro", "euros"),
("fr", '$') => ("dollar", "dollars"),
("fr", '£') => ("livre", "livres"),
("es", '€') => ("euro", "euros"),
("es", '$') => ("dólar", "dólares"),
("es", '£') => ("libra", "libras"),
("it", '€') => ("euro", "euro"),
("it", '$') => ("dollaro", "dollari"),
("it", '£') => ("sterlina", "sterline"),
("pt", '€') => ("euro", "euros"),
("pt", '$') => ("dólar", "dólares"),
("pt", '£') => ("libra", "libras"),
("nl", '€') => ("euro", "euro"),
("nl", '$') => ("dollar", "dollar"),
("nl", '£') => ("pond", "pond"),
_ => return None,
})
}
fn currency_one_word(lang: &str, c: char) -> Option<&'static str> {
match (primary_bcp47_subtag(lang), c) {
("de", _) => Some("ein"), ("es", '£') | ("it", '£') => Some("una"), ("es", _) | ("it", _) => Some("un"),
_ => None,
}
}
fn is_scale_word(w: &str) -> bool {
matches!(
w,
"thousand" | "million" | "billion" | "trillion" | "quadrillion" | "quintillion"
)
}
fn emit_currency_amount(
out: &mut Vec<Token>,
amount: &NumberToken,
sing: &str,
plur: &str,
split_cents: bool,
one_word: Option<&str>,
) {
let push_unit = |out: &mut Vec<Token>, n_str: &str, sing: &str, plur: &str| {
let one = n_str.trim_start_matches('0') == "1";
out.push(Token::Number(NumberToken::Cardinal(n_str.to_string())));
out.push(Token::Word(if one { sing } else { plur }.to_string()));
};
match amount {
NumberToken::Decimal { integer, fractional } if split_cents => {
let mut cents: String = fractional.chars().take(2).collect();
while cents.len() < 2 {
cents.push('0');
}
let dollars_zero = integer.chars().all(|d| d == '0');
let cents_zero = cents == "00";
if !dollars_zero || cents_zero {
push_unit(out, integer, sing, plur);
}
if !cents_zero {
push_unit(out, ¢s, "cent", "cents");
}
}
_ => {
let one = matches!(amount, NumberToken::Cardinal(s) if s == "1");
if let (true, Some(w)) = (one, one_word) {
out.push(Token::Word(w.to_string()));
} else {
out.push(Token::Number(amount.clone()));
}
out.push(Token::Word(if one { sing } else { plur }.to_string()));
}
}
}
fn apply_currency(tokens: &mut Vec<Token>, lang: &str) {
if !tokens.iter().any(|t| matches!(t, Token::Punctuation(c) if currency_words(*c, lang).is_some())) {
return;
}
let split_cents = primary_bcp47_subtag(lang) == "en";
let after = |i: usize| if matches!(tokens.get(i + 1), Some(Token::Space | Token::WordJoin)) { i + 2 } else { i + 1 };
let mut out = Vec::with_capacity(tokens.len());
let mut i = 0;
while i < tokens.len() {
if let Token::Punctuation(c) = tokens[i] {
if let Some((sing, plur)) = currency_words(c, lang) {
let j = after(i);
if let Some(Token::Number(n)) = tokens.get(j) {
let k = after(j);
if let Some(Token::Word(w)) = tokens.get(k) {
if is_scale_word(&w.to_lowercase()) {
out.push(Token::Number(n.clone()));
out.push(Token::Word(w.clone()));
out.push(Token::Word(plur.to_string()));
i = k + 1;
continue;
}
}
emit_currency_amount(&mut out, n, sing, plur, split_cents, currency_one_word(lang, c));
i = j + 1;
continue;
}
}
}
if let Token::Number(n) = &tokens[i] {
let j = after(i);
if let Some(Token::Punctuation(c)) = tokens.get(j) {
if let Some((sing, plur)) = currency_words(*c, lang) {
emit_currency_amount(&mut out, n, sing, plur, split_cents, currency_one_word(lang, *c));
i = j + 1;
continue;
}
}
}
out.push(tokens[i].clone());
i += 1;
}
*tokens = out;
}
fn lookup_symbol_name(
c: char,
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
options: &LangOptions,
) -> Vec<u8> {
let lang = primary_bcp47_subtag(&options.lang);
let word: Option<&str> = if lang == "en" {
match c {
'&' => Some("and"),
'+' => Some("plus"),
'@' => Some("at"),
'×' => Some("times"),
'÷' => Some("divided by"),
'°' => Some("degrees"),
'±' => Some("plus or minus"),
'−' => Some("minus"), '§' => Some("section"),
'№' => Some("numero"),
'∞' => Some("infinity"),
'√' => Some("square root"),
'∑' => Some("sum"),
'≈' => Some("approximately equal to"),
'≠' => Some("not equal to"),
'≤' => Some("less than or equal to"),
'≥' => Some("greater than or equal to"),
'℃' => Some("degrees celsius"),
'℉' => Some("degrees fahrenheit"),
'™' => Some("trademark"),
'®' => Some("registered trademark"),
'©' => Some("copyright"),
'‰' => Some("per thousand"),
'‱' => Some("per ten thousand"),
_ => None,
}
} else {
match c {
'+' => match lang {
"de" | "fr" | "nl" | "pl" | "cs" | "ro" | "sv" | "da" => Some("plus"),
"nb" => Some("pluss"),
"es" => Some("más"),
"pt" => Some("mais"),
"it" => Some("più"),
"ru" | "uk" | "bg" => Some("плюс"),
"tr" => Some("artı"),
"he" => Some("פְּלוּס"),
_ => None,
},
'−' => match lang {
"de" | "pl" | "cs" | "ro" | "sv" | "da" | "nb" => Some("minus"),
"fr" => Some("moins"),
"es" | "pt" => Some("menos"),
"it" => Some("meno"),
"nl" => Some("min"),
"ru" | "bg" => Some("минус"),
"uk" => Some("мінус"),
"tr" => Some("eksi"),
"he" => Some("מִנוּס"),
_ => None,
},
'°' => match lang {
"de" => Some("grad"),
"fr" => Some("degrés"),
"es" => Some("grados"),
"it" => Some("gradi"),
"nl" => Some("graden"),
"sv" | "da" | "nb" => Some("grader"),
"uk" => Some("градусів"),
"ru" => Some("градусов"),
"bg" => Some("градуса"),
"he" => Some("מַעֲלוֹת"),
_ => None,
},
'×' => match lang {
"de" => Some("mal"),
"fr" => Some("fois"),
"es" => Some("por"),
"it" => Some("per"),
"nl" => Some("keer"),
"pt" => Some("vezes"),
"pl" => Some("razy"),
"cs" => Some("krát"),
"ro" => Some("ori"),
"sv" => Some("gånger"),
"da" => Some("gange"),
"nb" => Some("ganger"),
"tr" => Some("çarpı"),
"ru" => Some("умножить на"),
"uk" => Some("помножити на"),
"bg" => Some("по"),
"he" => Some("כָּפוּל"),
_ => None,
},
'÷' => match lang {
"de" => Some("geteilt durch"),
"fr" => Some("divisé par"),
"es" => Some("dividido por"),
"it" => Some("diviso"),
"nl" => Some("gedeeld door"),
"pt" => Some("dividido por"),
"pl" => Some("przez"),
"cs" => Some("děleno"),
"ro" => Some("împărțit la"),
"sv" => Some("delat med"),
"da" => Some("divideret med"),
"nb" => Some("delt på"),
"tr" => Some("bölü"),
"ru" => Some("разделить на"),
"uk" => Some("поділити на"),
"bg" => Some("разделено на"),
"he" => Some("חֶלְקֵי"),
_ => None,
},
'±' => match lang {
"es" => Some("más menos"),
"it" => Some("più meno"),
"pt" => Some("mais menos"),
"pl" | "cs" | "ro" | "sv" | "da" => Some("plus minus"),
"nb" => Some("pluss minus"),
"tr" => Some("artı eksi"),
"ru" | "bg" => Some("плюс минус"),
"uk" => Some("плюс мінус"),
_ => None,
},
'%' => match lang {
"ru" | "bg" => Some("процент"),
"uk" => Some("відсоток"),
_ => None,
},
'№' => match lang {
"ru" | "bg" => Some("номер"),
"de" | "it" => Some("numero"),
"pt" => Some("número"),
"nl" => Some("nummer"),
"cs" => Some("číslo"),
_ => None,
},
_ => None,
}
};
if let Some(w) = word {
let mut out: Vec<u8> = Vec::new();
for part in w.split_whitespace() {
let wr = word_to_phonemes(part, dict, phdata, stress_opts, options);
let ph: &[u8] = wr.phonemes.strip_suffix(&[0]).unwrap_or(&wr.phonemes);
if ph.is_empty() {
continue;
}
if !out.is_empty() {
out.push(crate::phoneme::PHON_END_WORD);
}
out.extend_from_slice(ph);
}
if !out.is_empty() {
return out;
}
}
for key in [format!("_{c}"), c.to_string()] {
let wr = word_to_phonemes(&key, dict, phdata, stress_opts, options);
if !wr.phonemes.is_empty() {
return wr.phonemes;
}
}
Vec::new()
}
pub(crate) fn build_translate_entries(
tokens: &[Token],
dict: &Dictionary,
phdata: &PhonemeData,
stress_opts: &StressOpts,
options: &LangOptions,
) -> Vec<TranslateEntry> {
let mut entries: Vec<TranslateEntry> = Vec::with_capacity(tokens.len());
let mut prev_word: Option<String> = None;
let is_word = |t: &Token| matches!(t, Token::Word(_) | Token::Number(_));
let mut at_end = vec![false; tokens.len()];
let mut at_start = vec![false; tokens.len()];
let mut seen_word_in_clause = false;
for (i, t) in tokens.iter().enumerate() {
match t {
Token::ClauseBoundary(_) => seen_word_in_clause = false,
t if is_word(t) => {
at_start[i] = !seen_word_in_clause;
seen_word_in_clause = true;
at_end[i] = !tokens[i + 1..]
.iter()
.take_while(|t| !matches!(t, Token::ClauseBoundary(_)))
.any(is_word);
}
_ => {}
}
}
let (clause_lower, clause_upper) = {
let mut lower = vec![0usize; tokens.len()];
let mut upper = vec![0usize; tokens.len()];
let mut start = 0usize;
for i in 0..=tokens.len() {
if i == tokens.len() || matches!(tokens[i], Token::ClauseBoundary(_)) {
let (mut l, mut u) = (0usize, 0usize);
for t in &tokens[start..i] {
if let Token::Word(w) = t {
l += w.chars().filter(|c| c.is_lowercase()).count();
u += w.chars().filter(|c| c.is_uppercase()).count();
}
}
lower[start..i].fill(l);
upper[start..i].fill(u);
start = i + 1;
}
}
(lower, upper)
};
for (token_ix, token) in tokens.iter().enumerate() {
match token {
Token::Word(word) => {
let foreign = word
.chars()
.find_map(char_script)
.filter(|sc| !native_scripts(&options.lang).contains(sc));
if let Some(sc) = foreign {
if !script_uses_words(sc)
&& alt_alphabet_voice(&options.lang, sc).is_none()
&& !dict_knows_word(word, dict)
{
if let Some(ph) =
spell_foreign_word(word, dict, phdata, stress_opts, options)
{
entries.push(TranslateEntry {
phonemes: ph,
dict_flags: 0,
kind: TranslateEntryKind::Word,
word_lower: Some(word.to_lowercase()),
no_word_gap: false,
found_in_list: false,
});
prev_word = None;
continue;
}
}
}
let switch_target = foreign_script_voice(word, &options.lang)
.or_else(|| foreign.map(script_voice));
if let Some(target) = switch_target.filter(|_| !dict_knows_word(word, dict)) {
let mut phonemes = vec![crate::phoneme::PHON_SWITCH];
phonemes.extend_from_slice(target.as_bytes());
entries.push(TranslateEntry {
phonemes,
dict_flags: 0,
kind: TranslateEntryKind::Word, word_lower: Some(word.clone()),
no_word_gap: false,
found_in_list: false,
});
prev_word = None;
continue;
}
let lower = word.to_lowercase();
let letters = word.chars().filter(|c| c.is_alphabetic());
let mut next_word = [0u8; 8];
if let Some(nw) = tokens[token_ix + 1..]
.iter()
.take_while(|t| !matches!(t, Token::ClauseBoundary(_)))
.find_map(|t| match t {
Token::Word(w) => Some(w.to_lowercase()),
_ => None,
})
{
let b = nw.as_bytes();
let n = (0..=b.len().min(8))
.rev()
.find(|&k| nw.is_char_boundary(k))
.unwrap_or(0);
next_word[..n].copy_from_slice(&b[..n]);
}
let expect = PosExpect {
at_clause_end: at_end[token_ix],
at_clause_start: at_start[token_ix],
first_upper: word.chars().next().is_some_and(char::is_uppercase),
all_upper: letters.clone().next().is_some()
&& letters.clone().all(char::is_uppercase),
next_word,
..pos_expect_after(prev_word.as_deref())
};
let mut wr = word_to_phonemes_pos(&lower, dict, phdata, stress_opts, options, expect);
if !wr.found_in_list
&& wr.dict_flags & crate::dictionary::FLAG_ABBREV != 0
{
if let Some(spelled) =
spell_word_letters(&lower, dict, phdata, stress_opts, options)
{
wr.phonemes = spelled;
}
}
else if !wr.found_in_list && is_unpronounceable(&lower, dict, options) {
if let Some((letters, rest)) =
spell_unpronounceable(&lower, dict, phdata, stress_opts, options)
{
let mut ph = letters;
if !rest.is_empty() {
let tail =
word_to_phonemes(rest, dict, phdata, stress_opts, options);
ph.extend(tail.phonemes.iter().copied().filter(|&b| b != 0));
}
wr.phonemes = ph;
}
}
else if expect.all_upper
&& !wr.found_in_list
&& (2..4).contains(&word.chars().count())
&& word.chars().next().is_some_and(char::is_alphabetic)
&& clause_lower[token_ix] > 3
&& clause_upper[token_ix] <= clause_lower[token_ix]
{
if let Some(spelled) =
spell_word_letters(&lower, dict, phdata, stress_opts, options)
{
wr.phonemes = spelled;
wr.found_in_list = false;
}
}
prev_word = Some(lower.clone());
if matches!(
primary_bcp47_subtag(&options.lang),
"hi" | "mr" | "ne" | "sa" | "kok" | "bn" | "as" | "gu" | "pa"
) {
delete_final_schwa(&mut wr.phonemes, phdata);
}
entries.push(TranslateEntry {
phonemes: wr.phonemes,
dict_flags: wr.dict_flags,
found_in_list: wr.found_in_list,
kind: TranslateEntryKind::Word,
word_lower: Some(lower),
no_word_gap: false
});
}
Token::Number(token) => {
if options.number_grammar.portuguese_cardinals {
if let Some(word) = portuguese_number_word(token) {
let mut phonemes: Vec<u8> = Vec::new();
let mut last_flags = 0;
let parts: Vec<&str> = word.split_whitespace().collect();
for (pi, part) in parts.iter().enumerate() {
let wr = word_to_phonemes_pos(
part, dict, phdata, stress_opts, options,
expect_before(parts.get(pi + 1).copied()),
);
let ph = wr.phonemes.strip_suffix(&[0]).unwrap_or(&wr.phonemes);
if ph.is_empty() {
continue;
}
if !phonemes.is_empty() {
phonemes.push(crate::phoneme::PHON_END_WORD);
}
phonemes.extend_from_slice(ph);
last_flags = wr.dict_flags;
}
entries.push(TranslateEntry {
phonemes,
dict_flags: last_flags,
kind: TranslateEntryKind::Word,
word_lower: Some(word),
no_word_gap: false,
found_in_list: false,
});
continue;
}
}
if options.number_grammar.ordinals.italian {
if let NumberToken::Ordinal(ord) = token {
if let OrdinalMarker::Suffix(suffix) = &ord.marker {
if let Some(word) = italian_ordinal_word(&ord.digits, suffix) {
let wr = word_to_phonemes(&word, dict, phdata, stress_opts, options);
entries.push(TranslateEntry {
phonemes: wr.phonemes,
dict_flags: wr.dict_flags,
found_in_list: wr.found_in_list,
kind: TranslateEntryKind::Word,
word_lower: Some(word),
no_word_gap: false
});
continue;
}
}
}
}
if options.number_grammar.ordinals.french {
if let NumberToken::Ordinal(ord) = token {
if let OrdinalMarker::Suffix(suffix) = &ord.marker {
if let Some(word) = french_ordinal_word(&ord.digits, suffix) {
let mut phonemes: Vec<u8> = Vec::new();
let parts: Vec<&str> = word
.split(|c: char| c.is_whitespace() || c == '-')
.filter(|p| !p.is_empty())
.collect();
for (pi, part) in parts.iter().enumerate() {
let wr = word_to_phonemes_pos(
part, dict, phdata, stress_opts, options,
expect_before(parts.get(pi + 1).copied()),
);
let ph = wr.phonemes.strip_suffix(&[0]).unwrap_or(&wr.phonemes);
if ph.is_empty() {
continue;
}
if !phonemes.is_empty() {
phonemes.push(crate::phoneme::PHON_END_WORD);
}
phonemes.extend_from_slice(ph);
}
entries.push(TranslateEntry {
phonemes,
dict_flags: 0,
kind: TranslateEntryKind::Word,
word_lower: Some(word),
no_word_gap: false,
found_in_list: false,
});
continue;
}
let card = NumberToken::Cardinal(ord.digits.clone());
if let Some(wr) = translate_number_token(
&card, dict, phdata, stress_opts, &options.number_grammar,
) {
entries.push(TranslateEntry {
phonemes: wr.phonemes,
dict_flags: wr.dict_flags,
found_in_list: wr.found_in_list,
kind: TranslateEntryKind::Word,
word_lower: Some(ord.digits.clone()),
no_word_gap: false
});
continue;
}
}
}
}
let wr = translate_number_token(
token,
dict,
phdata,
stress_opts,
&options.number_grammar,
)
.unwrap_or_else(|| {
let surface = token.surface();
word_to_phonemes(&surface, dict, phdata, stress_opts, options)
});
entries.push(TranslateEntry {
phonemes: wr.phonemes,
dict_flags: wr.dict_flags,
found_in_list: wr.found_in_list,
kind: TranslateEntryKind::Word,
word_lower: Some(token.surface().to_string().to_lowercase()),
no_word_gap: false
});
}
Token::InlinePhonemes(content) => {
entries.push(TranslateEntry {
phonemes: parse_inline_phonemes(content, phdata),
dict_flags: 0,
kind: TranslateEntryKind::Word,
word_lower: None,
no_word_gap: false,
found_in_list: false,
});
}
Token::ClauseBoundary(c) => {
prev_word = None;
let name = if punct_covers(options, *c) {
lookup_symbol_name(*c, dict, phdata, stress_opts, options)
} else {
Vec::new()
};
if name.is_empty() {
entries.push(TranslateEntry {
phonemes: Vec::new(),
dict_flags: 0,
kind: TranslateEntryKind::ClauseBoundary,
word_lower: None,
no_word_gap: false,
found_in_list: false,
});
} else {
entries.push(TranslateEntry {
phonemes: name,
dict_flags: 0,
kind: TranslateEntryKind::Word,
word_lower: None,
no_word_gap: false,
found_in_list: false,
});
}
}
Token::Punctuation(c) if is_spoken_symbol(*c) || punct_covers(options, *c) => {
let phonemes = lookup_symbol_name(*c, dict, phdata, stress_opts, options);
if phonemes.is_empty() {
entries.push(TranslateEntry {
phonemes, dict_flags: 0,
kind: TranslateEntryKind::Other, word_lower: None,
no_word_gap: false,
found_in_list: false,
});
} else {
entries.push(TranslateEntry {
phonemes, dict_flags: 0,
kind: TranslateEntryKind::Word, word_lower: None,
no_word_gap: false,
found_in_list: false,
});
}
}
_ => {
entries.push(TranslateEntry {
phonemes: Vec::new(),
dict_flags: 0,
kind: TranslateEntryKind::Other,
word_lower: None,
no_word_gap: false,
found_in_list: false,
});
}
}
}
for e in &mut entries {
if e.kind == TranslateEntryKind::Word
&& e.phonemes.first() == Some(&crate::phoneme::PHON_SWITCH)
{
e.kind = TranslateEntryKind::LangSwitch;
}
}
entries
}
fn phoneme_mnemonic(code: u8, phdata: &PhonemeData) -> String {
phdata
.get(code)
.map(|ph| {
ph.mnemonic
.to_le_bytes()
.iter()
.take_while(|&&b| b != 0)
.map(|&b| b as char)
.collect()
})
.unwrap_or_default()
}
fn assign_default_tones(entries: &mut [TranslateEntry], phdata: &PhonemeData, base: &str) {
const PHON_DEFAULT_TONE: u8 = 17;
if phdata.get(PHON_DEFAULT_TONE).is_none_or(|ph| ph.typ != 1) {
return; }
let vi_final_tone = (base == "vi").then(|| phdata.lookup_phoneme("7")).filter(|&c| c != 0);
let mut last_toneless: Option<(usize, usize)> = None;
for ei in 0..entries.len() {
if entries[ei].kind != TranslateEntryKind::Word {
continue;
}
let mut i = 0;
while i < entries[ei].phonemes.len() {
let code = entries[ei].phonemes[i];
if code == 0 {
break;
}
if !matches!(phdata.get(code), Some(ph) if ph.typ == 2 ) {
i += 1;
continue;
}
let has_tone = entries[ei]
.phonemes
.get(i + 1)
.is_some_and(|&c| is_tone_phoneme(c, phdata));
if !has_tone {
entries[ei].phonemes.insert(i + 1, PHON_DEFAULT_TONE);
last_toneless = Some((ei, i + 1));
i += 1;
}
i += 1;
}
}
if let (Some(tone7), Some((ei, pos))) = (vi_final_tone, last_toneless) {
if entries[ei].phonemes.get(pos) == Some(&PHON_DEFAULT_TONE) {
let is_last_word = entries[ei + 1..]
.iter()
.all(|e| e.kind != TranslateEntryKind::Word);
if is_last_word {
entries[ei].phonemes[pos] = tone7;
}
}
}
}
fn apply_tone_sandhi(entries: &mut [TranslateEntry], phdata: &PhonemeData, lang: &str) {
let base = primary_bcp47_subtag(lang);
if !matches!(base, "cmn" | "zh" | "yue" | "hak" | "vi" | "shn") {
return;
}
if base != "cmn" && base != "zh" {
assign_default_tones(entries, phdata, base);
return;
}
let code_of = |m: &str| phdata.lookup_phoneme(m);
let mut prev_slot: Option<(usize, usize)> = None;
let mut prev_tone = String::new(); let mut prevw_tone = String::new(); let mut pause = true;
let mut promoted = false;
for ei in 0..entries.len() {
match entries[ei].kind {
TranslateEntryKind::Word => {}
TranslateEntryKind::ClauseBoundary => {
pause = true;
prevw_tone.clear();
prev_tone.clear();
continue;
}
_ => continue,
}
prev_tone.clear(); let mut i = 0;
while i < entries[ei].phonemes.len() {
let code = entries[ei].phonemes[i];
if code == 0 {
break;
}
let is_vowel = matches!(phdata.get(code), Some(ph) if ph.typ == 2);
if !is_vowel {
i += 1;
continue;
}
let tone_pos = i + 1;
let existing = entries[ei]
.phonemes
.get(tone_pos)
.copied()
.filter(|&c| is_tone_phoneme(c, phdata));
let tone_code = match existing {
Some(c) => {
promoted = false;
c
}
None => {
let mnem = if pause || promoted { "55" } else { "11" };
let c = code_of(mnem);
if c == 0 {
i += 1;
continue;
}
promoted = mnem == "55";
entries[ei].phonemes.insert(tone_pos, c);
c
}
};
let mut tone = phoneme_mnemonic(tone_code, phdata);
if prevw_tone == "214" {
if let Some((pe, pp)) = prev_slot {
let joins = tone == "214";
let new = if joins { code_of("35") } else { code_of("21") };
if new != 0 {
entries[pe].phonemes[pp] = new;
}
if joins {
entries[pe].no_word_gap = true;
entries[pe]
.phonemes
.retain(|&c| !matches!(c, PHON_STRESS_P | PHON_STRESS_P2));
}
}
}
if prev_tone == "51" && tone == "51" {
if let Some((pe, pp)) = prev_slot {
let new = code_of("53");
if new != 0 {
entries[pe].phonemes[pp] = new;
}
}
}
if tone == "11" {
let replacement = match prevw_tone.as_str() {
"55" => "22",
"53" => "33",
"214" => "44",
_ => "",
};
if !replacement.is_empty() {
let new = code_of(replacement);
if new != 0 {
entries[ei].phonemes[tone_pos] = new;
tone = replacement.to_string();
}
}
}
prev_slot = Some((ei, tone_pos));
prev_tone = tone.clone();
prevw_tone = tone;
pause = false;
i = tone_pos + 1;
}
}
}
pub(crate) fn promote_translate_entries(
entries: &mut [TranslateEntry],
phdata: &PhonemeData,
lang: &str,
) {
const FLAG_STREND: u32 = 1 << 9;
const FLAG_STREND2: u32 = 1 << 10;
const PHON_STRESS_P_CODE: u8 = 6;
const PHON_STRESS_P2_CODE: u8 = 7;
fn promote_clause(entries: &mut [TranslateEntry], phdata: &PhonemeData, lang: &str) {
let n = entries.len();
#[allow(clippy::needless_range_loop)]
for i in 0..n {
if entries[i].kind != TranslateEntryKind::Word {
continue;
}
let dict_flags = entries[i].dict_flags;
if dict_flags & (FLAG_STREND | FLAG_STREND2) == 0 {
continue;
}
let is_last_word = entries[i + 1..]
.iter()
.all(|e| e.kind != TranslateEntryKind::Word);
let following_all_unstressed = entries[i + 1..]
.iter()
.filter(|e| e.kind == TranslateEntryKind::Word)
.all(|e| {
!e.phonemes
.iter()
.any(|&c| c == PHON_STRESS_P_CODE || c == PHON_STRESS_P2_CODE)
});
let phonemes = &mut entries[i].phonemes;
match phonemes.iter().rposition(|&c| c == crate::phoneme::PHON_END_WORD) {
Some(sep) => {
let mut tail = phonemes[sep + 1..].to_vec();
promote_strend_stress(
&mut tail,
phdata,
dict_flags,
is_last_word,
following_all_unstressed,
);
phonemes.truncate(sep + 1);
phonemes.extend(tail);
}
None => promote_strend_stress(
phonemes,
phdata,
dict_flags,
is_last_word,
following_all_unstressed,
),
}
}
let has_primary = entries
.iter()
.filter(|e| e.kind == TranslateEntryKind::Word)
.any(|e| {
e.phonemes
.iter()
.any(|&c| c == PHON_STRESS_P_CODE || c == PHON_STRESS_P2_CODE)
});
if !has_primary {
let last_secondary = entries
.iter()
.enumerate()
.rev()
.find(|(_, e)| {
e.kind == TranslateEntryKind::Word
&& !e.phonemes.is_empty()
&& e.phonemes.iter().any(|&c| c == 4 || c == 5)
})
.map(|(i, _)| i);
if let Some(idx) = last_secondary {
change_word_stress(&mut entries[idx].phonemes, phdata, 4);
} else {
let last_word = entries
.iter()
.enumerate()
.rev()
.find(|(_, e)| {
e.kind == TranslateEntryKind::Word && !e.phonemes.is_empty()
})
.map(|(i, _)| i);
if let Some(idx) = last_word {
change_word_stress(&mut entries[idx].phonemes, phdata, 4);
}
}
}
if primary_bcp47_subtag(lang) == "en" {
apply_en_wh_clause_initial_secondary(entries, phdata);
}
apply_tone_sandhi(entries, phdata, lang);
}
fn apply_en_wh_clause_initial_secondary(
entries: &mut [TranslateEntry],
phdata: &PhonemeData,
) {
const PHON_STRESS_2: u8 = 4;
const PHON_STRESS_P: u8 = 6;
const PHON_STRESS_P2: u8 = 7;
const WH: &[&str] = &[
"when", "where", "what", "who", "why", "how", "which", "while",
];
let word_ix: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == TranslateEntryKind::Word && !e.phonemes.is_empty())
.map(|(i, _)| i)
.collect();
if word_ix.len() < 2 {
return;
}
let i0 = word_ix[0];
let i1 = word_ix[1];
let Some(w) = entries[i0].word_lower.as_deref() else {
return;
};
if !WH.iter().any(|&kw| kw == w) {
return;
}
if !entries[i1]
.phonemes
.iter()
.any(|&c| c == PHON_STRESS_P || c == PHON_STRESS_P2)
{
return;
}
let ph = &entries[i0].phonemes;
let Some(fv) = ph
.iter()
.position(|&c| phdata.get(c).map(|p| p.typ == 2).unwrap_or(false))
else {
return;
};
if ph[..fv].iter().any(|&c| matches!(c, 4 | 5 | 6 | 7)) {
return;
}
let mut new_ph = ph.to_vec();
new_ph.insert(fv, PHON_STRESS_2);
entries[i0].phonemes = new_ph;
}
let clause_boundaries: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| e.kind == TranslateEntryKind::ClauseBoundary)
.map(|(i, _)| i)
.collect();
let lang_for_promo = lang;
if clause_boundaries.is_empty() {
promote_clause(entries, phdata, lang_for_promo);
} else {
let mut prev_end = 0usize;
let mut boundaries_with_end = clause_boundaries.clone();
boundaries_with_end.push(entries.len());
for &bound in &boundaries_with_end {
let slice_end = if bound < entries.len() { bound } else { entries.len() };
if slice_end > prev_end {
promote_clause(&mut entries[prev_end..slice_end], phdata, lang_for_promo);
}
prev_end = if bound < entries.len() { bound + 1 } else { entries.len() };
}
}
}
pub(crate) fn apply_en_or_linking_r(entries: &mut [TranslateEntry], phdata: &PhonemeData) {
let r_code = phdata.lookup_phoneme("r");
if r_code == 0 {
return;
}
fn next_word_vowel_initial(entries: &[TranslateEntry], from: usize, phdata: &PhonemeData) -> bool {
entries[from + 1..]
.iter()
.find(|e| e.kind == TranslateEntryKind::Word && !e.phonemes.is_empty())
.map(|e| {
e.phonemes
.iter()
.find(|&&c| c > 8 && c != 15)
.and_then(|&c| phdata.get(c))
.map(|ph| ph.typ == 2)
.unwrap_or(false)
})
.unwrap_or(false)
}
for i in 0..entries.len() {
if entries[i].kind != TranslateEntryKind::Word {
continue;
}
if entries[i].word_lower.as_deref() != Some("or") {
continue;
}
if !next_word_vowel_initial(entries, i, phdata) {
continue;
}
while entries[i].phonemes.last() == Some(&0) {
entries[i].phonemes.pop();
}
entries[i].phonemes.push(r_code);
entries[i].phonemes.push(0);
}
}
pub struct Translator {
pub options: LangOptions,
data_dir: PathBuf,
}
impl Translator {
pub fn new(lang: &str, data_dir: Option<&Path>) -> Result<Self> {
let dir = data_dir
.map(|p| p.to_path_buf())
.unwrap_or_else(|| PathBuf::from(default_data_dir()));
let (base, _variant) = split_voice_variant(lang);
let mut options = LangOptions::for_lang(base);
options.dict_condition = crate::voices::voice_dict_condition(&dir, base);
Ok(Translator { options, data_dir: dir })
}
pub fn new_default(lang: &str) -> Result<Self> {
Self::new(lang, None)
}
pub fn read_clauses(&self, text: &str) -> Result<Vec<Clause>> {
let mut clauses = Vec::new();
let mut current = String::new();
for c in text.chars() {
match c {
'.' | '!' | '?' => {
current.push(c);
let intonation = match c {
'!' => Intonation::Exclamation,
'?' => Intonation::Question,
_ => Intonation::FullStop,
};
let text_trim = current.trim().to_string();
if !text_trim.is_empty() {
clauses.push(Clause {
text: text_trim,
intonation,
clause_type: ClauseType::Sentence,
pause_ms: 400,
});
}
current = String::new();
}
',' | ';' | ':' => {
current.push(c);
}
_ => { current.push(c); }
}
}
let text_trim = current.trim().to_string();
if !text_trim.is_empty() {
clauses.push(Clause {
text: text_trim,
intonation: Intonation::None,
clause_type: ClauseType::Eof,
pause_ms: 0,
});
}
if clauses.is_empty() {
clauses.push(Clause {
text: text.trim().to_string(),
intonation: Intonation::None,
clause_type: ClauseType::Eof,
pause_ms: 0,
});
}
Ok(clauses)
}
pub fn text_to_ipa(&self, text: &str) -> Result<String> {
self.text_to_ipa_with_options(text, false, false, false, true)
}
pub fn text_to_ipa_with_terminator(
&self,
text: &str,
) -> Result<(String, ClauseTerminator)> {
let ipa = self.text_to_ipa(text)?;
Ok((ipa, clause_terminator_of(text)))
}
pub fn text_to_ipa_ssml(&self, text: &str) -> Result<String> {
self.text_to_ipa_with_options(text, false, false, true, true)
}
pub(crate) fn text_to_ipa_with_options(
&self,
text: &str,
preserve_punctuation: bool,
flatten_clauses: bool,
markup: bool,
allow_switch: bool,
) -> Result<String> {
let stripped;
let text = if markup {
let segments = ssml::process_markup(text);
if segments.iter().any(|s| s.lang.is_some()) {
let mut voice_cache: std::collections::HashMap<String, Translator> =
std::collections::HashMap::new();
let sep = if flatten_clauses { " " } else { "\n" };
let mut parts: Vec<String> = Vec::new();
for seg in &segments {
if seg.text.trim().is_empty() {
continue;
}
let resolved = seg
.lang
.as_deref()
.filter(|l| *l != self.options.lang)
.and_then(|l| resolve_voice_lang(l, &self.data_dir))
.filter(|lang| *lang != self.options.lang);
let translator: &Translator = match resolved {
Some(lang) => {
if !voice_cache.contains_key(&lang) {
if let Ok(tr) = Translator::new(&lang, Some(&self.data_dir)) {
voice_cache.insert(lang.clone(), tr);
}
}
voice_cache.get(&lang).unwrap_or(self)
}
None => self,
};
let seg_text =
interpret_segment_text(&seg.text, seg.interpret, &translator.options.lang);
let ipa = translator.text_to_ipa_with_options(
&seg_text, preserve_punctuation, flatten_clauses, false, allow_switch,
)?;
if !ipa.trim().is_empty() {
parts.push(ipa);
}
}
return Ok(parts.join(sep));
}
stripped = segments
.into_iter()
.map(|s| interpret_segment_text(&s.text, s.interpret, &self.options.lang))
.collect::<String>();
stripped.as_str()
} else {
text
};
let codes = self.translate_to_codes_inner(text, allow_switch)?;
let mut phdata = PhonemeData::load(&self.data_dir)?;
select_phoneme_table(&mut phdata, &self.data_dir, &self.options.lang)?;
let mut ipa_out = phoneme_ipa::codes_to_ipa(
&codes,
&phdata,
&self.data_dir,
&self.options.lang,
preserve_punctuation,
);
if flatten_clauses {
ipa_out = ipa_out.replace('\n', " ");
}
Ok(ipa_out)
}
pub fn translate_to_codes(&self, text: &str) -> Result<Vec<PhonemeCode>> {
self.translate_to_codes_inner(text, true)
}
pub(crate) fn translate_to_codes_inner(
&self,
text: &str,
allow_switch: bool,
) -> Result<Vec<PhonemeCode>> {
let lang = &self.options.lang;
let dict_stem = resolve_dict_stem(&self.data_dir, lang).ok_or_else(|| {
Error::NotImplemented("translate_to_codes: dict not found")
})?;
let dict_path = dict_path(&self.data_dir, &dict_stem).ok_or_else(|| {
Error::NotImplemented("translate: dict not found")
})?;
let phontab_path = self.data_dir.join("phontab");
let dict_bytes = std::fs::read(&dict_path).map_err(Error::Io)?;
let mut dict = Dictionary::from_bytes(&dict_stem, dict_bytes)?;
dict.dict_condition = self.options.dict_condition;
if !phontab_path.exists() {
return Err(Error::NotImplemented("translate_to_codes: phontab not found"));
}
let mut phdata = PhonemeData::load(&self.data_dir)?;
select_phoneme_table(&mut phdata, &self.data_dir, lang)?;
let stress_opts = StressOpts::for_lang_in(lang, &self.data_dir);
let cap_codes: Vec<u8> = match self.options.capitals {
1 => vec![crate::phoneme::PHON_PAUSE_SHORT, crate::phoneme::PHON_CAPITAL],
2 => word_to_phonemes("_cap", &dict, &phdata, &stress_opts, &self.options).phonemes,
_ => Vec::new(),
};
let mut tokens = tokenize_opts(text, &self.options.number_grammar);
apply_roman_numerals(&mut tokens, lang);
suppress_abbreviation_periods(&mut tokens, lang);
apply_time_reading(&mut tokens, lang);
apply_dash_reading(&mut tokens, lang);
apply_decades(&mut tokens, lang);
apply_units(&mut tokens, lang);
apply_temperature(&mut tokens, lang);
apply_abbreviations(&mut tokens, lang);
apply_dimensions(&mut tokens, lang);
apply_number_abbrev(&mut tokens, lang);
apply_space_grouping(&mut tokens, &self.options.number_grammar);
apply_currency(&mut tokens, lang);
apply_fractions(&mut tokens, lang);
apply_exponents(&mut tokens, lang);
let mut entries = build_translate_entries(
&tokens,
&dict,
&phdata,
&stress_opts,
&self.options,
);
promote_translate_entries(&mut entries, &phdata, lang);
if primary_bcp47_subtag(lang) == "en" {
apply_en_or_linking_r(&mut entries, &phdata);
}
debug_assert_eq!(tokens.len(), entries.len());
let mut codes: Vec<PhonemeCode> = Vec::new();
let mut from_dict: Vec<bool> = Vec::new();
let mut switch_cache: std::collections::HashMap<String, Translator> =
std::collections::HashMap::new();
let mut open_switch: Option<String> = None;
for (token_ix, (token, entry)) in tokens.iter().zip(entries.iter()).enumerate() {
let translation_given = entry.found_in_list;
if matches!(entry.kind, TranslateEntryKind::LangSwitch) && allow_switch {
let target = switch_target(&entry.phonemes);
let word = entry.word_lower.as_deref().unwrap_or("");
if !word.is_empty() {
if !switch_cache.contains_key(&target) {
if let Ok(tr) = Translator::new(&target, Some(&self.data_dir)) {
switch_cache.insert(target.clone(), tr);
}
}
let sw = switch_cache
.get(&target)
.and_then(|tr| tr.translate_to_codes_inner(word, false).ok());
if let Some(sw) = sw {
if open_switch.as_deref() != Some(target.as_str()) {
codes.push(PhonemeCode {
marker: Some(CodeMarker::LangSwitch(target.clone())),
..Default::default()
});
open_switch = Some(target.clone());
}
for c in sw {
if c.is_boundary && c.code == 0 {
continue;
}
codes.push(c);
}
codes.push(PhonemeCode { code: 15, is_boundary: true, ..Default::default() });
from_dict.resize(codes.len(), false);
continue;
}
}
}
if open_switch.is_some() && !entry.phonemes.is_empty() {
open_switch = None;
codes.push(PhonemeCode {
marker: Some(CodeMarker::LangSwitch(self.options.lang.clone())),
..Default::default()
});
}
match token {
Token::Embedded(cmd) => {
codes.push(PhonemeCode {
marker: Some(CodeMarker::Embedded(*cmd)),
..Default::default()
});
from_dict.resize(codes.len(), false);
}
Token::Word(_)
| Token::Number(_)
| Token::InlinePhonemes(_)
| Token::Punctuation(_) => {
if let Token::Punctuation(c) = token {
codes.push(PhonemeCode {
marker: Some(CodeMarker::Punctuation(*c)),
..Default::default()
});
}
let joined = matches!(
token_ix.checked_sub(1).map(|k| &tokens[k]),
Some(Token::WordJoin)
) || (0..token_ix)
.rev()
.find(|&k| !matches!(tokens[k], Token::Space | Token::WordJoin))
.and_then(|k| entries.get(k))
.is_some_and(|e| e.no_word_gap);
let runs_on = codes
.iter()
.rev()
.find(|c| c.marker.is_none())
.is_some_and(|c| !c.is_boundary && c.code != crate::phoneme::PHON_END_WORD);
if runs_on && !joined && entry.phonemes.iter().any(|&b| b != 0) {
codes.push(PhonemeCode {
code: 15, is_boundary: true, clause_char: None, marker: None,
});
}
if !cap_codes.is_empty() {
if let Token::Word(w) = token {
if w.chars().next().is_some_and(char::is_uppercase) {
let alpha = w.chars().filter(|c| c.is_alphabetic());
let all_upper = alpha.clone().count() > 1
&& alpha.clone().all(|c| c.is_uppercase());
let repeats = if self.options.capitals == 1 && all_upper { 2 } else { 1 };
for _ in 0..repeats {
for &b in &cap_codes {
if b != 0 {
codes.push(PhonemeCode {
code: b, is_boundary: false, clause_char: None, marker: None
});
}
}
}
codes.push(PhonemeCode {
code: 15, is_boundary: true, clause_char: None, marker: None
}); }
}
}
if !entry.phonemes.is_empty() {
use crate::dictionary::{FLAG_PAUSE1, FLAG_PREPAUSE};
let mut pre_pause = 0;
if entry.dict_flags & FLAG_PAUSE1 != 0 {
pre_pause = 1;
}
if entry.dict_flags & FLAG_PREPAUSE != 0 {
let is_word = |t: &Token| {
matches!(t, Token::Word(_) | Token::Number(_) | Token::InlinePhonemes(_))
};
let before = tokens[..token_ix]
.iter()
.rev()
.take_while(|t| !matches!(t, Token::ClauseBoundary(_)))
.filter(|t| is_word(t))
.count();
let after = tokens[token_ix + 1..]
.iter()
.take_while(|t| !matches!(t, Token::ClauseBoundary(_)))
.filter(|t| is_word(t))
.count();
if before >= 2 && after >= 1 {
pre_pause = 4;
}
}
let at = codes
.iter()
.rposition(|c| {
!c.is_boundary
&& c.marker.is_none()
&& c.code != crate::phoneme::PHON_END_WORD
})
.map_or(0, |i| i + 1);
let mut n = 0;
while pre_pause > 0 {
let code = if pre_pause > 1 {
pre_pause -= 2;
crate::phoneme::PHON_PAUSE
} else {
pre_pause -= 1;
crate::phoneme::PHON_PAUSE_NOLINK
};
codes.insert(
at + n,
PhonemeCode {
code, is_boundary: false, clause_char: None, marker: None,
},
);
n += 1;
}
if n > 0 {
from_dict.resize(codes.len(), false);
}
}
for &b in &entry.phonemes {
if b != 0 && b != crate::phoneme::PHON_X1 {
codes.push(PhonemeCode {
code: b,
is_boundary: false,
clause_char: None, marker: None
});
}
}
if self.options.word_gap > 0 && !entry.phonemes.is_empty() {
let pause = phdata.lookup_phoneme("_");
if pause != 0 {
codes.push(PhonemeCode {
code: pause, is_boundary: false, clause_char: None, marker: None
});
}
}
}
Token::WordJoin => {}
Token::Space => {
let joined = token_ix
.checked_sub(1)
.and_then(|k| entries.get(k))
.is_some_and(|e| e.no_word_gap);
if !joined {
codes.push(PhonemeCode {
code: 15,
is_boundary: true,
clause_char: None, marker: None
}); }
}
Token::ClauseBoundary(punct) => {
if entry.phonemes.is_empty() {
codes.push(PhonemeCode {
code: 0,
is_boundary: true,
clause_char: Some(*punct), marker: None
}); } else {
codes.push(PhonemeCode { code: 15, is_boundary: true, clause_char: None , marker: None});
for &b in &entry.phonemes {
if b != 0 {
codes.push(PhonemeCode {
code: b, is_boundary: false, clause_char: None, marker: None
});
}
}
codes.push(PhonemeCode { code: 15, is_boundary: true, clause_char: None , marker: None});
}
}
}
from_dict.resize(codes.len(), translation_given);
}
if open_switch.take().is_some() {
codes.push(PhonemeCode {
marker: Some(CodeMarker::LangSwitch(self.options.lang.clone())),
..Default::default()
});
from_dict.resize(codes.len(), false);
}
let mut out: Vec<PhonemeCode> = Vec::with_capacity(codes.len());
let mut own: Vec<PhonemeCode> = Vec::new();
let mut own_flags: Vec<bool> = Vec::new();
let mut in_switch = false;
let finish = |own: &mut Vec<PhonemeCode>,
own_flags: &mut Vec<bool>,
out: &mut Vec<PhonemeCode>| {
if own.is_empty() {
return;
}
own_flags.resize(own.len(), false);
resolve_virtual_phonemes_in_codes(own, &phdata, own_flags, stress_opts.reduce);
apply_list_editing_instructions(own, &phdata);
if primary_bcp47_subtag(lang) == "fr" {
drop_unsurfaced_liaison(own, &phdata);
}
out.append(own);
own_flags.clear();
};
for (ix, c) in codes.into_iter().enumerate() {
if let Some(CodeMarker::LangSwitch(target)) = &c.marker {
finish(&mut own, &mut own_flags, &mut out);
in_switch = *target != self.options.lang;
out.push(c);
continue;
}
if in_switch {
out.push(c);
} else {
own_flags.push(from_dict.get(ix).copied().unwrap_or(false));
own.push(c);
}
}
finish(&mut own, &mut own_flags, &mut out);
if stress_opts.regressive_voicing != 0 {
set_regressive_voicing(&mut out, &phdata, stress_opts.regressive_voicing);
}
let replacements: Vec<(u8, u8, u8)> = crate::voices::voice_replacements(&self.data_dir, lang)
.into_iter()
.filter_map(|(f, old, new)| {
let o = phdata.lookup_phoneme(&old);
let n = if new == "NULL" { 0 } else { phdata.lookup_phoneme(&new) };
(o != 0).then_some((f, o, n))
})
.collect();
apply_voice_replacements(&mut out, &phdata, &replacements);
if self.options.word_gap == 0 {
insert_word_gaps(&mut out, &phdata, stress_opts.vowel_pause, stress_opts.word_gap);
}
Ok(out)
}
}
fn insert_word_gaps(
codes: &mut Vec<PhonemeCode>,
phdata: &PhonemeData,
vowel_pause: u32,
word_gap: u8,
) {
use crate::phoneme::{PHON_END_WORD, PHON_PAUSE, PHON_PAUSE_LONG, PHON_PAUSE_NOLINK,
PHON_PAUSE_SHORT, PHON_PAUSE_VSHORT};
const PH_VOWEL: u8 = 2;
const PH_PAUSE: u8 = 0;
const PAUSE_PHONEMES: [u8; 8] = [
0, PHON_PAUSE_VSHORT, PHON_PAUSE_SHORT, PHON_PAUSE, PHON_PAUSE_LONG,
0, PHON_PAUSE_LONG, PHON_PAUSE_LONG,
];
if vowel_pause == 0 && word_gap & 7 == 0 {
return;
}
let is_sound = |c: &PhonemeCode| {
c.marker.is_none() && !c.is_boundary && c.code > 8 && c.code != PHON_END_WORD
};
let real: Vec<usize> = codes
.iter()
.enumerate()
.filter(|(_, c)| is_sound(c))
.map(|(i, _)| i)
.collect();
let (level, _) = stress_levels(codes, phdata);
let starts = word_starts(codes, &real);
let typ = |code: u8| phdata.get(code).map(|p| p.typ).unwrap_or(PH_PAUSE);
let mut inserts: Vec<(usize, u8)> = Vec::new();
for n in 1..=real.len() {
let at_clause_end = n == real.len()
|| codes[real[n - 1] + 1..real[n]]
.iter()
.any(|c| c.is_boundary && c.code == 0);
if !at_clause_end && !starts[n] {
continue; }
let prev = codes[real[n - 1]].code;
let next = if n < real.len() && !at_clause_end { codes[real[n]].code } else { 0 };
let (pt, nt) = (typ(prev), typ(next));
let mut insert = 0u8;
if vowel_pause != 0 && pt != PH_PAUSE {
if pt != PH_VOWEL && vowel_pause & 0x200 != 0 {
insert = PHON_PAUSE_NOLINK;
}
if nt == PH_VOWEL {
match vowel_pause & 0x0c {
0 => {}
0xc => insert = PHON_PAUSE_NOLINK,
_ => insert = PHON_PAUSE_VSHORT,
}
if pt == PH_VOWEL {
match vowel_pause & 0x03 {
0 => {}
2 => insert = PHON_PAUSE_SHORT,
_ => insert = PHON_PAUSE_VSHORT,
}
}
if vowel_pause & 0x100 != 0 && level.get(real[n]).is_some_and(|&l| l >= 4) {
insert = PHON_PAUSE_SHORT;
}
}
}
let x = (word_gap & 7) as usize;
if x != 0 && (x > 1 || (insert != PHON_PAUSE_SHORT && insert != PHON_PAUSE_NOLINK)) {
insert = PAUSE_PHONEMES[x];
}
if insert != 0 && phdata.get(insert).is_some() {
inserts.push((real[n - 1] + 1, insert));
}
}
for (at, code) in inserts.into_iter().rev() {
codes.insert(
at,
PhonemeCode { code, is_boundary: false, clause_char: None, marker: None },
);
}
}
fn apply_voice_replacements(
codes: &mut Vec<PhonemeCode>,
phdata: &PhonemeData,
rules: &[(u8, u8, u8)],
) {
if rules.is_empty() {
return;
}
let is_sound = |c: &PhonemeCode| {
c.marker.is_none() && !c.is_boundary && c.code > 8 && c.code != crate::phoneme::PHON_END_WORD
};
let real: Vec<usize> = codes
.iter()
.enumerate()
.filter(|(_, c)| is_sound(c))
.map(|(i, _)| i)
.collect();
let (level, _) = stress_levels(codes, phdata);
let starts = word_starts(codes, &real);
let mut deleted: Vec<usize> = Vec::new();
for (n, &i) in real.iter().enumerate() {
let stress = if level[i] != NO_STRESS_LEVEL {
level[i]
} else {
real[n + 1..]
.iter()
.enumerate()
.take_while(|(k, _)| !starts[n + 1 + k])
.find_map(|(_, &j)| (level[j] != NO_STRESS_LEVEL).then(|| level[j]))
.unwrap_or(1)
};
let word_end = match real.get(n + 1) {
None => true,
Some(&next) => {
starts[n + 1] || matches!(phdata.get(codes[next].code), Some(p) if p.typ == 0)
}
};
for &(flags, old, new) in rules {
if codes[i].code != old {
continue;
}
if flags & 1 != 0 && !word_end {
continue;
}
if flags & 2 != 0 && stress & 7 > 3 {
continue;
}
if flags & 4 != 0 && !starts[n] {
continue;
}
if new == 0 {
deleted.push(i);
} else {
codes[i].code = new;
}
break;
}
}
for &i in deleted.iter().rev() {
codes.remove(i);
}
}
fn drop_unsurfaced_liaison(codes: &mut Vec<PhonemeCode>, phdata: &PhonemeData) {
let is_sound = |c: &PhonemeCode| {
!c.is_boundary && c.code > 8 && c.code != crate::phoneme::PHON_END_WORD
};
let real: Vec<usize> = codes.iter().enumerate().filter(|(_, c)| is_sound(c)).map(|(i, _)| i).collect();
let mut drop: Vec<usize> = Vec::new();
for (n, &i) in real.iter().enumerate() {
let Some(ph) = phdata.get(codes[i].code) else { continue };
let m = ph.mnemonic.to_le_bytes();
let is_liaison = m[2] == 0 && matches!(m[1], b'2' | b'3') && ph.typ != 2;
if !is_liaison {
continue;
}
let next_is_vowel = real
.get(n + 1)
.and_then(|&k| phdata.get(codes[k].code))
.is_some_and(|n| n.typ == 2 );
if !next_is_vowel {
drop.push(i);
}
}
for &i in drop.iter().rev() {
codes.remove(i);
}
}
fn set_regressive_voicing(codes: &mut [PhonemeCode], phdata: &PhonemeData, regression: u32) {
use crate::phoneme::{PH_FRICATIVE, PH_PAUSE, PH_STOP, PH_VFRICATIVE, PH_VOWEL, PH_VSTOP};
let mut voicing = 0u8;
let mut stop_propagation = false;
for i in (0..codes.len()).rev() {
if codes[i].is_boundary || codes[i].marker.is_some() {
if regression & 0x04 != 0 {
voicing = 0;
}
if regression & 0x100 != 0 && voicing == 0 {
voicing = 1;
}
continue;
}
let code = codes[i].code;
let Some(ph) = phdata.get(code) else { continue };
if regression & 0x02 != 0 {
let first = (ph.mnemonic & 0xff) as u8;
if first == b'v' || first == b'R' {
stop_propagation = true;
if regression & 0x10 != 0 {
voicing = 0;
}
}
}
let switch_to = |want_voiced: bool| -> Option<u8> {
let t = ph.end_type;
let target = phdata.get(t)?;
let ok = if want_voiced {
matches!(target.typ, PH_VSTOP | PH_VFRICATIVE)
} else {
matches!(target.typ, PH_STOP | PH_FRICATIVE)
};
(t != 0 && ok).then_some(t)
};
match ph.typ {
PH_STOP | PH_FRICATIVE => {
if voicing == 0 && regression & 0xf != 0 {
voicing = 1;
} else if voicing == 2 {
if let Some(t) = switch_to(true) {
codes[i].code = t; }
}
}
PH_VSTOP | PH_VFRICATIVE => {
if voicing == 0 && regression & 0xf != 0 {
voicing = 2;
} else if voicing == 1 {
if let Some(t) = switch_to(false) {
codes[i].code = t; }
}
}
t => {
if regression & 0x08 != 0 {
if t == PH_PAUSE || t == PH_VOWEL {
voicing = 0;
}
} else {
voicing = 0;
}
}
}
if stop_propagation {
voicing = 0;
stop_propagation = false;
}
}
}
pub(crate) fn word_starts(codes: &[PhonemeCode], real: &[usize]) -> Vec<bool> {
let mut out = Vec::with_capacity(real.len());
let mut prev_end = 0usize;
for (n, &i) in real.iter().enumerate() {
out.push(
n == 0
|| codes[prev_end..i]
.iter()
.any(|c| c.is_boundary || c.code == crate::phoneme::PHON_END_WORD),
);
prev_end = i + 1;
}
out
}
pub(crate) fn vowel_positions(codes: &[PhonemeCode], phdata: &PhonemeData) -> Vec<u8> {
let mut out = vec![0u8; codes.len()];
let mut n = 0u8;
for (i, c) in codes.iter().enumerate() {
if c.is_boundary || c.code == crate::phoneme::PHON_END_WORD {
n = 0;
continue;
}
if matches!(phdata.get(c.code), Some(p) if p.typ == 2 ) {
n = n.saturating_add(1);
out[i] = n;
}
}
out
}
pub(crate) fn stress_levels(
codes: &[PhonemeCode],
phdata: &PhonemeData,
) -> (Vec<u8>, Vec<u8>) {
let mut level = vec![NO_STRESS_LEVEL; codes.len()];
let mut pending = 1u8; for (i, c) in codes.iter().enumerate() {
if c.is_boundary || c.code == crate::phoneme::PHON_END_WORD {
pending = 1;
continue;
}
match c.code {
PHON_STRESS_D => pending = 0,
PHON_STRESS_U => pending = 1,
PHON_STRESS_2 => pending = 2,
PHON_STRESS_3 => pending = 3,
PHON_STRESS_P => pending = 4,
PHON_STRESS_P2 => pending = 5,
PHON_STRESS_TONIC => pending = 6,
code => {
if matches!(phdata.get(code), Some(p) if p.typ == 2 ) {
level[i] = pending;
pending = 1;
}
}
}
}
let mut wordstress = vec![0u8; codes.len()];
let mut start = 0usize;
let mut i = 0usize;
while i <= codes.len() {
let ends = i == codes.len()
|| codes[i].is_boundary
|| codes[i].code == crate::phoneme::PHON_END_WORD;
if ends {
let max = level[start..i]
.iter()
.filter(|&&l| l != NO_STRESS_LEVEL)
.copied()
.max()
.unwrap_or(4);
wordstress[start..i].fill(max);
start = i + 1;
}
i += 1;
}
(level, wordstress)
}
pub(crate) const NO_STRESS_LEVEL: u8 = u8::MAX;
fn apply_list_editing_instructions(codes: &mut Vec<PhonemeCode>, phdata: &PhonemeData) {
let is_sound = |c: &PhonemeCode| {
!c.is_boundary && c.code > 8 && c.code != crate::phoneme::PHON_END_WORD
};
let real: Vec<usize> = codes.iter().enumerate().filter(|(_, c)| is_sound(c)).map(|(i, _)| i).collect();
if real.is_empty() {
return;
}
let starts = word_starts(codes, &real);
let mut edits: Vec<(usize, u8, bool)> = Vec::new();
let mut replacements: Vec<(usize, u8)> = Vec::new();
for (n, &i) in real.iter().enumerate() {
let code = codes[i].code;
let Some(ph) = phdata.get(code) else { continue };
if ph.program == 0 {
continue;
}
let prev = n.checked_sub(1).map(|k| codes[real[k]].code).unwrap_or(0);
let next = real.get(n + 1).map(|&k| codes[k].code).unwrap_or(0);
let next2 = real.get(n + 2).map(|&k| codes[k].code).unwrap_or(0);
let next_wordstart = real.get(n + 1).is_some_and(|&k| {
codes[i + 1..k]
.iter()
.any(|c| c.is_boundary || c.code == crate::phoneme::PHON_END_WORD)
});
let nb = crate::synthesize::bytecode::Neighbours {
prev,
this: code,
next,
next2,
this_wordstart: starts[n],
prev_wordstart: n.checked_sub(1).is_some_and(|k| starts[k]),
next_wordstart,
next2_wordstart: next_wordstart,
..Default::default()
};
let fx = crate::synthesize::bytecode::interpret_phoneme_ctl(
ph.program,
&phdata.phonindex,
&nb,
|c| phdata.get(c).cloned(),
true,
);
if let Some(c) = fx.insert_phoneme.filter(|&c| c != 0) {
edits.push((i, c, true));
}
let appended = fx.append_phoneme.filter(|&c| c != 0).or_else(|| {
fx.append_if_next_vowel
.filter(|&c| c != 0)
.filter(|_| matches!(phdata.get(next), Some(n) if n.typ == 2 ))
});
if let Some(c) = appended {
edits.push((i, c, false));
}
if let Some(c) = fx.replace_next_phoneme.filter(|&c| c != 0) {
if let Some(&k) = real.get(n + 1) {
replacements.push((k, c));
}
}
}
for (i, c) in replacements {
codes[i].code = c;
}
if edits.is_empty() {
return;
}
edits.sort_by(|a, b| b.0.cmp(&a.0).then(b.2.cmp(&a.2)));
for (i, code, before) in edits {
let at = if before { i } else { i + 1 };
codes.insert(at, PhonemeCode { code, is_boundary: false, clause_char: None , marker: None});
}
}
fn resolve_virtual_phonemes_in_codes(
codes: &mut Vec<PhonemeCode>,
phdata: &PhonemeData,
from_dict: &[bool],
reduce: u32,
) {
let (level, wordstress) = stress_levels(codes, phdata);
let vowel_pos = vowel_positions(codes, phdata);
let real: Vec<usize> = codes
.iter()
.enumerate()
.filter(|(_, c)| !c.is_boundary && c.code > 8 && c.code != crate::phoneme::PHON_END_WORD)
.map(|(i, _)| i)
.collect();
let starts = word_starts(codes, &real);
let mut deleted: Vec<usize> = Vec::new();
for (n, &i) in real.iter().enumerate() {
let code = codes[i].code;
let Some(ph) = phdata.get(code) else { continue };
if ph.program == 0 {
continue;
}
let prev = n.checked_sub(1).map(|k| codes[real[k]].code).unwrap_or(0);
let next = real.get(n + 1).map(|&k| codes[k].code).unwrap_or(0);
let next2 = real.get(n + 2).map(|&k| codes[k].code).unwrap_or(0);
let next_wordstart = real.get(n + 1).is_some_and(|&k| {
codes[i + 1..k]
.iter()
.any(|c| c.is_boundary || c.code == crate::phoneme::PHON_END_WORD)
});
let next_ix = real.get(n + 1).copied();
let nb = crate::synthesize::bytecode::Neighbours {
prev,
this: code,
next,
next2,
stress: level[i],
next_stress: next_ix.map(|k| level[k]).unwrap_or(NO_STRESS_LEVEL),
prev_stress: n
.checked_sub(1)
.and_then(|k| real.get(k))
.map(|&k| level[k])
.unwrap_or(NO_STRESS_LEVEL),
next2_stress: real.get(n + 2).map(|&k| level[k]).unwrap_or(NO_STRESS_LEVEL),
wordstress: wordstress[i],
vowel_position: vowel_pos[i],
reduce,
translation_given: from_dict.get(i).copied().unwrap_or(false),
this_wordstart: starts[n],
prev_wordstart: n.checked_sub(1).is_some_and(|k| starts[k]),
next_wordstart,
next2_wordstart: next_wordstart,
};
let extract = crate::synthesize::bytecode::interpret_phoneme_ctl(
ph.program,
&phdata.phonindex,
&nb,
|c| phdata.get(c).cloned(),
true,
);
if let Some(changed) = extract.change_phoneme_code.filter(|&c| c != 0 && c != code) {
if changed == 1 {
deleted.push(i);
} else {
codes[i].code = changed;
}
}
}
for &i in deleted.iter().rev() {
codes.remove(i);
}
}
fn switch_target(phonemes: &[u8]) -> String {
let target: String = phonemes
.iter()
.skip(1)
.take_while(|&&b| b.is_ascii_alphanumeric() || b == b'-')
.map(|&b| (b as char).to_ascii_lowercase())
.collect();
if target.is_empty() { "en".to_string() } else { target }
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PhonemeCode {
pub code: u8,
pub is_boundary: bool,
pub clause_char: Option<char>,
pub marker: Option<CodeMarker>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CodeMarker {
LangSwitch(String),
Embedded(EmbeddedCmd),
Punctuation(char),
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn lang_number_grammars_are_independent() {
let g = |lang: &str| LangOptions::for_lang(lang).number_grammar;
assert!(g("en").hundreds.use_conjunction_with_remainder, "en: 'and' before remainder");
assert_eq!(g("es").tens, TensGrammar::WithConjunction, "es: 'treinta y cuatro'");
assert!(g("es").hundreds.omit_one_prefix);
assert!(g("fr").hundreds.omit_one_prefix, "fr: 'cent' not 'un cent'");
assert_eq!(g("de").tens, TensGrammar::UnitsThenConjunction, "de: 'vier und dreißig'");
assert!(g("de").ordinals.dot_marks_ordinal, "de: '3.' is ordinal");
assert!(!g("fr").ordinals.dot_marks_ordinal, "fr must not take de's dot-ordinal");
assert_ne!(g("fr").tens, TensGrammar::UnitsThenConjunction, "fr must not take de's tens order");
assert_ne!(g("en").tens, TensGrammar::WithConjunction, "en must not take es's tens conjunction");
assert!(!g("en").hundreds.omit_one_prefix, "en keeps 'one hundred'");
assert!(!g("en").thousands.omit_one_prefix, "en keeps 'one thousand'");
assert!(!g("de").hundreds.omit_one_prefix && !g("de").thousands.omit_one_prefix, "de keeps 'ein'");
assert!(g("es").hundreds.omit_one_prefix && g("es").thousands.omit_one_prefix, "es: 'cien'/'mil'");
assert!(g("nl").ordinals.compound_cardinal_suffix, "nl: compound ordinals suffix the cardinal");
assert!(!g("mt").ordinals.compound_cardinal_suffix, "mt: does not");
let mut nl = g("nl");
nl.dutch_ij = false;
nl.ordinals.compound_cardinal_suffix = false;
assert_eq!(nl, g("mt"), "nl and mt share the same number arm (apart from Dutch-only flags)");
assert_eq!(g("da").tens, TensGrammar::UnitsThenConjunction, "da: 'enogtyve'");
assert!(g("da").ordinals.compound_cardinal_suffix, "da: compound ordinals suffix the cardinal");
assert_eq!(g("fi").tens, TensGrammar::Standard, "fi: tens-first 'kaksikymmentäyksi'");
assert_ne!(g("da"), g("fi"), "da (units-first) must differ from fi (tens-first)");
assert_eq!(g("fi"), g("et"), "fi and et still share the tens-first arm");
assert_ne!(g("nl"), g("de"), "nl (own arm) differs from de");
assert_ne!(g("da"), g("nl"), "da differs from nl's fuller config");
assert_eq!(g("zz"), NumberGrammar::default(), "unknown lang → default grammar");
assert_eq!(g("xyz"), NumberGrammar::default());
}
fn contains_subsequence(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() {
return true;
}
let mut needle_ix = 0;
for &byte in haystack {
if byte == needle[needle_ix] {
needle_ix += 1;
if needle_ix == needle.len() {
return true;
}
}
}
false
}
#[test]
fn translator_new_default_succeeds() {
let t = Translator::new_default("en").unwrap();
assert_eq!(t.options.lang, "en");
assert_eq!(t.options.rate, 175);
}
#[test]
fn normalize_voice_tag_us_underscore() {
assert_eq!(normalize_voice_tag("en_US"), "en-us");
assert_eq!(normalize_voice_tag(" EN-us "), "en-us");
}
#[test]
fn split_voice_variant_cases() {
assert_eq!(split_voice_variant("en"), ("en", None));
assert_eq!(split_voice_variant("en+f3"), ("en", Some("f3")));
assert_eq!(split_voice_variant("en-us+m3"), ("en-us", Some("m3")));
assert_eq!(split_voice_variant("en+whisper"), ("en", Some("whisper")));
assert_eq!(split_voice_variant("en+"), ("en", None));
assert_eq!(split_voice_variant("+f3"), ("+f3", None));
}
#[test]
fn translator_new_strips_variant() {
let t = Translator::new_default("en+f3").unwrap();
assert_eq!(t.options.lang, "en");
}
#[test]
fn lang_options_en_us_matches_en_number_grammar() {
let o = LangOptions::for_lang("en-US");
assert_eq!(o.lang, "en-us");
assert_eq!(o.number_grammar, LangOptions::for_lang("en").number_grammar);
}
#[test]
fn translator_new_default_normalizes_voice_tag() {
let t = Translator::new_default("en_US").unwrap();
assert_eq!(t.options.lang, "en-us");
}
#[test]
fn tokenize_hello_world() {
let tokens = tokenize("hello world");
assert_eq!(tokens, vec![
Token::Word("hello".to_string()),
Token::Space,
Token::Word("world".to_string()),
]);
}
#[test]
fn tokenize_with_punctuation() {
let tokens = tokenize("hello, world!");
assert!(tokens.iter().any(|t| t == &Token::Word("hello".to_string())));
assert!(tokens.iter().any(|t| t == &Token::Word("world".to_string())));
assert!(tokens.iter().any(|t| t == &Token::ClauseBoundary(',')));
assert!(tokens.iter().any(|t| t == &Token::ClauseBoundary('!')));
}
#[test]
fn comma_groups_thousands_for_english() {
let en = NumberGrammar::for_lang("en");
assert_eq!(
tokenize_opts("1,000", &en),
vec![Token::Number(NumberToken::Cardinal("1000".into()))]
);
assert_eq!(
tokenize_opts("1,234,567", &en),
vec![Token::Number(NumberToken::Cardinal("1234567".into()))]
);
assert_eq!(
tokenize_opts("1,000.5", &en),
vec![Token::Number(NumberToken::Decimal { integer: "1000".into(), fractional: "5".into() })]
);
assert!(
tokenize_opts("1,0000", &en).iter().any(|t| t == &Token::ClauseBoundary(',')),
"4-digit group must not be absorbed"
);
assert!(
tokenize_opts("1,000", &NumberGrammar::default())
.iter()
.any(|t| t == &Token::ClauseBoundary(',')),
"no grouping without a configured separator"
);
}
#[test]
fn currency_symbol_reorders_and_pluralizes() {
let en = NumberGrammar::for_lang("en");
let rewrite = |s: &str, lang: &str| {
let mut t = tokenize_opts(s, &NumberGrammar::for_lang(lang));
apply_currency(&mut t, lang);
t
};
assert_eq!(
rewrite("$5", "en"),
vec![Token::Number(NumberToken::Cardinal("5".into())), Token::Word("dollars".into())]
);
assert_eq!(
rewrite("$1", "en"),
vec![Token::Number(NumberToken::Cardinal("1".into())), Token::Word("dollar".into())]
);
assert_eq!(
rewrite("€ 10", "en"),
vec![Token::Number(NumberToken::Cardinal("10".into())), Token::Word("euros".into())]
);
assert_eq!(
rewrite("$5", "de"),
vec![Token::Number(NumberToken::Cardinal("5".into())), Token::Word("dollar".into())]
);
assert!(rewrite("¥5", "de").iter().any(|t| matches!(t, Token::Punctuation('¥'))));
assert_eq!(rewrite("$", "en"), vec![Token::Punctuation('$')]);
let cents = |s: &str| -> Vec<String> {
rewrite(s, "en").iter().filter_map(|t| match t {
Token::Word(w) => Some(w.clone()),
Token::Number(NumberToken::Cardinal(n)) => Some(n.clone()),
_ => None,
}).collect()
};
assert_eq!(cents("$5.99"), vec!["5", "dollars", "99", "cents"]);
assert_eq!(cents("$1.01"), vec!["1", "dollar", "01", "cent"]); assert_eq!(cents("$1.00"), vec!["1", "dollar"]); assert_eq!(cents("$0.99"), vec!["99", "cents"]); assert_eq!(cents("$5.5"), vec!["5", "dollars", "50", "cents"]);
assert_eq!(
rewrite("10€", "en"),
vec![Token::Number(NumberToken::Cardinal("10".into())), Token::Word("euros".into())]
);
assert_eq!(cents("5$"), vec!["5", "dollars"]);
assert_eq!(cents("99¢"), vec!["99", "cents"]);
assert_eq!(cents("5.99€"), vec!["5", "euros", "99", "cents"]);
assert_eq!(cents("$5 million"), vec!["5", "million", "dollars"]);
assert_eq!(cents("$1 million"), vec!["1", "million", "dollars"]); assert_eq!(cents("$5 each"), vec!["5", "dollars", "each"]);
assert_eq!(
rewrite("$1.2 billion", "en"),
vec![
Token::Number(NumberToken::Decimal { integer: "1".into(), fractional: "2".into() }),
Token::Word("billion".into()),
Token::Word("dollars".into()),
]
);
let _ = en;
}
#[test]
fn numeric_exponent_is_spoken() {
let mut t = tokenize_opts("2^10", &NumberGrammar::for_lang("en"));
apply_exponents(&mut t, "en");
let words: Vec<String> = t.iter().filter_map(|t| match t {
Token::Word(w) => Some(w.clone()),
Token::Number(NumberToken::Cardinal(n)) => Some(n.clone()),
_ => None,
}).collect();
assert_eq!(words, vec!["2", "to", "the", "power", "of", "10"]);
let mut t2 = tokenize_opts("x^2", &NumberGrammar::for_lang("en"));
apply_exponents(&mut t2, "en");
assert!(t2.iter().any(|t| matches!(t, Token::Punctuation('^'))));
}
#[test]
fn simple_fractions_are_spoken() {
let rewrite = |s: &str, lang: &str| {
let mut t = tokenize_opts(s, &NumberGrammar::for_lang(lang));
apply_fractions(&mut t, lang);
t
};
let words = |s: &str| -> Vec<String> {
rewrite(s, "en").iter().filter_map(|t| match t {
Token::Word(w) => Some(w.clone()),
Token::Number(NumberToken::Cardinal(n)) => Some(n.clone()),
_ => None,
}).collect()
};
assert_eq!(words("1/2"), vec!["1", "half"]);
assert_eq!(words("3/4"), vec!["3", "quarters"]);
assert_eq!(words("2/3"), vec!["2", "thirds"]);
assert_eq!(words("½"), vec!["1", "half"]); assert_eq!(words("1½"), vec!["1", "and", "1", "half"]);
assert_eq!(words("2¾"), vec!["2", "and", "3", "quarters"]);
assert!(rewrite("3/4/2024", "en").iter().any(|t| matches!(t, Token::Punctuation('/'))));
assert!(rewrite("1/12", "en").iter().any(|t| matches!(t, Token::Punctuation('/'))));
assert!(rewrite("1/2", "de").iter().any(|t| matches!(t, Token::Punctuation('/'))));
}
#[test]
fn leading_decimal_point_is_a_number() {
let en = NumberGrammar::for_lang("en");
assert_eq!(
tokenize_opts(".5", &en),
vec![Token::Number(NumberToken::Decimal { integer: "0".into(), fractional: "5".into() })]
);
assert_eq!(
tokenize_opts(".25", &en),
vec![Token::Number(NumberToken::Decimal { integer: "0".into(), fractional: "25".into() })]
);
assert!(
tokenize_opts("5.", &en).iter().any(|t| matches!(t, Token::ClauseBoundary('.'))),
"trailing dot must stay a sentence boundary"
);
let de = NumberGrammar::for_lang("de");
assert_eq!(
tokenize_opts(",5", &de),
vec![Token::Number(NumberToken::Decimal { integer: "0".into(), fractional: "5".into() })]
);
}
#[test]
fn native_script_digits_become_numbers() {
assert_eq!(native_digit_to_ascii('٥'), Some('5')); assert_eq!(native_digit_to_ascii('५'), Some('5')); assert_eq!(native_digit_to_ascii('۵'), Some('5')); assert_eq!(native_digit_to_ascii('๕'), Some('5')); assert_eq!(native_digit_to_ascii('a'), None);
assert_eq!(normalize_number_symbols("١٢٣"), "123");
let g = NumberGrammar::default();
assert_eq!(tokenize_opts("٥", &g), vec![Token::Number(NumberToken::Cardinal("5".into()))]);
assert_eq!(tokenize_opts("١٢٣", &g), vec![Token::Number(NumberToken::Cardinal("123".into()))]);
assert_eq!(normalize_number_symbols("١٫٥"), "1.5"); assert_eq!(normalize_number_symbols("١٬٠٠٠"), "1,000"); assert_eq!(normalize_number_symbols("٥٪"), "5%");
assert_eq!(fullwidth_to_ascii('5'), Some('5'));
assert_eq!(fullwidth_to_ascii('A'), Some('A'));
assert_eq!(fullwidth_to_ascii('$'), Some('$'));
assert_eq!(fullwidth_to_ascii('\u{3000}'), Some(' ')); assert_eq!(fullwidth_to_ascii('5'), None);
assert_eq!(normalize_number_symbols("Hello"), "Hello");
assert_eq!(normalize_number_symbols("123"), "123");
assert_eq!(enclosed_number_value('①'), Some(1));
assert_eq!(enclosed_number_value('⑩'), Some(10));
assert_eq!(enclosed_number_value('Ⅳ'), Some(4));
assert_eq!(enclosed_number_value('Ⅿ'), Some(1000));
assert_eq!(enclosed_number_value('5'), None);
assert!(tokenize_opts("①", &g).iter()
.any(|t| t == &Token::Number(NumberToken::Cardinal("1".into()))));
}
#[test]
fn unicode_number_symbols_are_normalized() {
use std::borrow::Cow;
assert_eq!(normalize_number_symbols("x²"), "x squared ");
assert_eq!(normalize_number_symbols("10³"), "10 cubed ");
assert_eq!(normalize_number_symbols("H₂O"), "H 2O");
assert_eq!(normalize_number_symbols("½"), " 1/2 ");
assert_eq!(normalize_number_symbols("3½"), "3 1/2 ");
assert_eq!(normalize_number_symbols("⅔"), " 2/3 ");
assert!(matches!(normalize_number_symbols("hello"), Cow::Borrowed("hello")));
assert_eq!(normalize_number_symbols("𝐇𝐞𝐥𝐥𝐨"), "Hello"); assert_eq!(normalize_number_symbols("𝓗𝓮𝓵𝓵𝓸"), "Hello"); assert_eq!(normalize_number_symbols("𝔻𝕒𝕥𝕒"), "Data"); assert_eq!(normalize_number_symbols("Ⓗⓘ"), "Hi"); assert_eq!(normalize_number_symbols("𝟏𝟐𝟑"), "123"); assert_eq!(normalize_number_symbols("ℝℤℕ"), "RZN"); assert_eq!(stylized_to_ascii('𝐀'), Some('A'));
assert_eq!(stylized_to_ascii('𝐳'), Some('z'));
assert_eq!(stylized_to_ascii('A'), None);
let en = NumberGrammar::for_lang("en");
assert!(
tokenize_opts("x²", &en).iter().any(|t| t == &Token::Word("squared".into())),
"superscript ² should read 'squared'"
);
assert!(
tokenize_opts("H₂", &en).iter().any(|t| t == &Token::Number(NumberToken::Cardinal("2".into()))),
"subscript should still yield a number token"
);
}
#[test]
fn non_ordinal_suffix_keeps_the_number() {
let en = NumberGrammar::for_lang("en");
assert_eq!(
tokenize_opts("1990s", &en),
vec![
Token::Number(NumberToken::Cardinal("1990".into())),
Token::Word("s".into()),
]
);
assert_eq!(
tokenize_opts("5km", &en),
vec![
Token::Number(NumberToken::Cardinal("5".into())),
Token::Word("km".into()),
]
);
assert!(matches!(
tokenize_opts("3rd", &en).as_slice(),
[Token::Number(NumberToken::Ordinal(o))] if o.digits == "3"
));
assert!(matches!(
tokenize_opts("21st", &en).as_slice(),
[Token::Number(NumberToken::Ordinal(o))] if o.digits == "21"
));
for (input, num, letters) in [("2st", "2", "st"), ("1nd", "1", "nd"),
("3th", "3", "th"), ("11st", "11", "st")] {
assert_eq!(
tokenize_opts(input, &en),
vec![
Token::Number(NumberToken::Cardinal(num.into())),
Token::Word(letters.into()),
],
"{input} should be cardinal + word",
);
}
assert!(matches!(
tokenize_opts("11th", &en).as_slice(),
[Token::Number(NumberToken::Ordinal(o))] if o.digits == "11"
));
}
#[test]
fn european_decimal_and_grouping_are_swapped() {
let de = NumberGrammar::for_lang("de");
assert_eq!((de.decimal_separator, de.group_separator), (',', Some('.')));
assert_eq!(
tokenize_opts("1.000", &de),
vec![Token::Number(NumberToken::Cardinal("1000".into()))]
);
assert_eq!(
tokenize_opts("3,14", &de),
vec![Token::Number(NumberToken::Decimal { integer: "3".into(), fractional: "14".into() })]
);
assert_eq!(
tokenize_opts("1.000,5", &de),
vec![Token::Number(NumberToken::Decimal { integer: "1000".into(), fractional: "5".into() })]
);
let en = NumberGrammar::for_lang("en");
assert_eq!((en.decimal_separator, en.group_separator), ('.', Some(',')));
assert_eq!(
tokenize_opts("3.14", &en),
vec![Token::Number(NumberToken::Decimal { integer: "3".into(), fractional: "14".into() })]
);
}
#[test]
fn tokenize_empty() {
assert!(tokenize("").is_empty());
}
#[test]
fn tokenize_apostrophe() {
let tokens = tokenize("it's");
assert_eq!(tokens, vec![Token::Word("it's".to_string())]);
}
#[test]
fn clause_flags_fields_do_not_overlap() {
assert!(
(ClauseFlags::PAUSE_MASK & ClauseFlags::INTONATION_MASK).is_empty()
);
assert!(
(ClauseFlags::INTONATION_MASK & ClauseFlags::TYPE_MASK).is_empty()
);
}
#[test]
fn read_clauses_basic() {
let t = Translator::new_default("en").unwrap();
let clauses = t.read_clauses("Hello world. How are you?").unwrap();
assert_eq!(clauses.len(), 2);
assert_eq!(clauses[0].intonation, Intonation::FullStop);
assert_eq!(clauses[1].intonation, Intonation::Question);
}
#[test]
fn read_clauses_no_punctuation() {
let t = Translator::new_default("en").unwrap();
let clauses = t.read_clauses("hello world").unwrap();
assert_eq!(clauses.len(), 1);
assert_eq!(clauses[0].text, "hello world");
}
fn make_phdata() -> Option<PhonemeData> {
let dir = std::path::Path::new("/usr/share/espeak-ng-data");
if !dir.join("phontab").exists() { return None; }
let mut phdata = PhonemeData::load(dir).ok()?;
phdata.select_table_by_name("en").ok()?;
Some(phdata)
}
#[test]
fn phonemes_to_ipa_the() {
let phdata = match make_phdata() { Some(d) => d, None => return };
let (ipa, _) = phonemes_to_ipa(&[87, 115], &phdata, PendingStress::None, false);
assert_eq!(ipa, "ðə");
}
#[test]
fn phonemes_to_ipa_be() {
let phdata = match make_phdata() { Some(d) => d, None => return };
let (ipa, _) = phonemes_to_ipa(&[72, 137], &phdata, PendingStress::None, false);
assert_eq!(ipa, "biː");
}
#[test]
fn phonemes_to_ipa_with_stress() {
let phdata = match make_phdata() { Some(d) => d, None => return };
let (ipa, _) = phonemes_to_ipa(&[4, 50, 129, 47], &phdata, PendingStress::None, false);
assert_eq!(ipa, "nˌɒt");
}
#[test]
fn text_to_ipa_be() {
let t = Translator::new_default("en").unwrap();
if !Path::new("/usr/share/espeak-ng-data/en_dict").exists() { return; }
let ipa = t.text_to_ipa("be").unwrap();
assert_eq!(ipa, "bˈiː");
}
#[test]
fn text_to_ipa_en_us_shares_en_dict_but_not_its_phoneme_table() {
let data_dir = Path::new("espeak-ng-data");
let data_dir = if data_dir.join("en_dict").exists() {
data_dir
} else if Path::new("/usr/share/espeak-ng-data/en_dict").exists() {
Path::new("/usr/share/espeak-ng-data")
} else {
return;
};
let t = Translator::new("en_US", Some(data_dir)).unwrap();
assert_eq!(t.options.lang, "en-us");
let ipa = t.text_to_ipa("hello").unwrap();
assert!(!ipa.is_empty());
let t_en = Translator::new("en", Some(data_dir)).unwrap();
assert_eq!(ipa, "həlˈoʊ");
assert_eq!(t_en.text_to_ipa("hello").unwrap(), "həlˈəʊ");
}
#[test]
fn word_to_ipa_unseen_retranslates_stem_after_un_prefix() {
let data_dir = Path::new("espeak-ng-data");
let data_dir = if data_dir.join("en_dict").exists() {
data_dir
} else if Path::new("/usr/share/espeak-ng-data/en_dict").exists() {
Path::new("/usr/share/espeak-ng-data")
} else {
return;
};
let t = Translator::new("en-us", Some(data_dir)).unwrap();
let ipa = t.text_to_ipa("unseen").unwrap();
assert!(
ipa.contains("sˈiːn") || ipa.contains("siːn"),
"expected 'seen' syllable in output: {ipa:?}"
);
}
#[test]
fn word_to_ipa_seen_or_unseen_us_has_no_control_chars() {
let data_dir = Path::new("espeak-ng-data");
let data_dir = if data_dir.join("en_dict").exists() {
data_dir
} else if Path::new("/usr/share/espeak-ng-data/en_dict").exists() {
Path::new("/usr/share/espeak-ng-data")
} else {
return;
};
let t = Translator::new("en-us", Some(data_dir)).unwrap();
let ipa = t.text_to_ipa("seen or unseen,").unwrap();
assert!(
!ipa.chars().any(|c| c.is_control()),
"IPA must not contain C0 controls: {ipa:?}"
);
}
#[test]
fn github_issue4_translate_codes_matches_ipa_pipeline() {
let data_dir = Path::new("espeak-ng-data");
let data_dir = if data_dir.join("en_dict").exists() {
data_dir
} else if Path::new("/usr/share/espeak-ng-data/en_dict").exists() {
Path::new("/usr/share/espeak-ng-data")
} else {
return;
};
let mut phdata = PhonemeData::load(data_dir).unwrap();
phdata.select_table_by_name("en-us").unwrap();
let r_code = phdata.lookup_phoneme("r");
assert!(r_code > 0, "en-us phontab should define r");
let t = Translator::new("en-us", Some(data_dir)).unwrap();
let wh = t.translate_to_codes("When choices cease").unwrap();
let flat: Vec<u8> = wh
.iter()
.filter(|c| !c.is_boundary)
.map(|c| c.code)
.collect();
assert!(
flat.len() >= 3 && flat[0] == 58 && flat[1] == 4,
"expected w then secondary stress (4) for clause-initial when: {flat:?}"
);
let or_line = t.translate_to_codes("seen or unseen").unwrap();
let flat: Vec<u8> = or_line
.iter()
.filter(|c| !c.is_boundary)
.map(|c| c.code)
.collect();
assert!(
flat.windows(2).any(|w| w[0] == 140 && w[1] == r_code),
"expected linking /r/ phoneme after dict tail of or (140): {flat:?}"
);
}
#[test]
fn text_to_ipa_he() {
let t = Translator::new_default("en").unwrap();
if !Path::new("/usr/share/espeak-ng-data/en_dict").exists() { return; }
let ipa = t.text_to_ipa("he").unwrap();
assert_eq!(ipa, "hˈiː");
}
#[test]
fn text_to_ipa_do() {
let t = Translator::new_default("en").unwrap();
if !Path::new("/usr/share/espeak-ng-data/en_dict").exists() { return; }
let ipa = t.text_to_ipa("do").unwrap();
assert_eq!(ipa, "dˈuː");
}
#[test]
fn text_to_ipa_the() {
let t = Translator::new_default("en").unwrap();
if !Path::new("/usr/share/espeak-ng-data/en_dict").exists() { return; }
let ipa = t.text_to_ipa("the").unwrap();
assert_eq!(ipa, "ðˈə");
}
#[test]
fn tokenize_chinese_chars_are_individual_words() {
let tokens = tokenize("你好世界");
assert_eq!(tokens, vec![
Token::Word("你".to_string()),
Token::Space,
Token::Word("好".to_string()),
Token::Space,
Token::Word("世".to_string()),
Token::Space,
Token::Word("界".to_string()),
]);
}
#[test]
fn tokenize_cjk_with_spaces() {
let tokens = tokenize("你好 世界");
assert_eq!(tokens, vec![
Token::Word("你".to_string()),
Token::Space,
Token::Word("好".to_string()),
Token::Space,
Token::Word("世".to_string()),
Token::Space,
Token::Word("界".to_string()),
]);
}
#[test]
fn tokenize_mixed_cjk_and_latin() {
let tokens = tokenize("Hello你好World世界");
assert_eq!(tokens, vec![
Token::Word("Hello".to_string()),
Token::Word("你".to_string()),
Token::Space,
Token::Word("好".to_string()),
Token::Word("World".to_string()),
Token::Word("世".to_string()),
Token::Space,
Token::Word("界".to_string()),
]);
}
#[test]
fn tokenize_single_cjk_char() {
let tokens = tokenize("你");
assert_eq!(tokens, vec![Token::Word("你".to_string())]);
}
#[test]
fn tokenize_cjk_with_punctuation() {
let tokens = tokenize("你好,世界!");
assert!(tokens.contains(&Token::Word("你".to_string())));
assert!(tokens.contains(&Token::Word("好".to_string())));
assert!(tokens.contains(&Token::Word("世".to_string())));
assert!(tokens.contains(&Token::Word("界".to_string())));
}
fn run_ipa_table(lang: &str, dict_name: &str, cases: &[(&str, &str)]) {
let dict_path = format!("espeak-ng-data/{dict_name}");
if !Path::new(&dict_path).exists() { return; }
let t = Translator::new_default(lang).unwrap();
for &(input, expected) in cases {
let ipa = t.text_to_ipa(input).unwrap();
assert_eq!(ipa, expected, "lang={lang} input={input:?}");
}
}
#[test]
fn text_to_ipa_english_rule_regressions() {
run_ipa_table("en", "en_dict", &[
("sky", "skˈaɪ"),
("caused", "kˈɔːzd"),
("reflection", "ɹɪflˈɛkʃən"),
("droplets", "dɹˈɒplɪts"),
("appearing", "ɐpˈiəɹɪŋ"),
("meteorological", "mˌiːtɪˌɔːɹəlˈɒdʒɪkəl"),
]);
}
#[test]
fn text_to_ipa_english_sentence_weak_forms() {
let t = Translator::new_default("en").unwrap();
if !Path::new("/usr/share/espeak-ng-data/en_dict").exists() { return; }
let ipa = t.text_to_ipa("A rainbow is a meteorological phenomenon that is caused by reflection, refraction and dispersion of light in water droplets resulting in a spectrum of light appearing in the sky.").unwrap();
assert_eq!(
ipa,
"ɐ ɹˈeɪnbəʊ ɪz ɐ mˌiːtɪˌɔːɹəlˈɒdʒɪkəl fɪnˈɒmɪnən ðat ɪz kˈɔːzd baɪ ɹɪflˈɛkʃən\nɹɪfɹˈakʃən and dɪspˈɜːʃən ɒv lˈaɪt ɪn wˈɔːtə dɹˈɒplɪts ɹɪzˈʌltɪŋ ɪn ɐ spˈɛktɹəm ɒv lˈaɪt ɐpˈiəɹɪŋ ɪnðə skˈaɪ"
);
}
#[test]
fn ordinals_english() {
run_ipa_table("en", "en_dict", &[
("1st", "fˈɜːst"),
("2nd", "sˈɛkənd"),
("3rd", "θˈɜːd"),
("4th", "fˈɔːθ"),
("21st", "twˈɛnti fˈɜːst"),
("100th","wˈɒnhˈʌndɹɪdθ"),
]);
}
#[test]
fn ordinals_english_large_scales() {
run_ipa_table("en", "en_dict", &[
("1000th", "wˈɒn θˈaʊzəndθ"),
("1001st", "wˈɒn θˈaʊzənd fˈɜːst"),
("1000000th", "wˈɒn mˈɪliənθ"),
]);
}
#[test]
fn ordinals_spanish() {
run_ipa_table("es", "es_dict", &[
("1º", "pɾimˈɛɾo"),
("21º", "βixˈɛsimˌo pɾimˈɛɾo"),
("100º", "θɛntˈɛsimo"),
]);
}
#[test]
fn ordinals_spanish_large_scale_do_not_use_hundred_root() {
let dict_path = "espeak-ng-data/es_dict";
if !Path::new(dict_path).exists() { return; }
let data_dir = Path::new("espeak-ng-data");
let dict = Dictionary::load("es", data_dir).unwrap();
let mut phdata = PhonemeData::load(data_dir).unwrap();
phdata.select_table_by_name("es").unwrap();
let stress_opts = StressOpts::for_lang("es");
let grammar = LangOptions::for_lang("es").number_grammar;
let ordinal = OrdinalNumber {
digits: "1000000".to_string(),
marker: OrdinalMarker::Suffix("º".to_string()),
};
let result = try_ordinal_number(&ordinal, &dict, &phdata, &stress_opts, &grammar).unwrap();
let hundred_ordinal_lookup = lookup_num_phonemes(&dict, "_0Co");
let hundred_ordinal = trim_lookup(&hundred_ordinal_lookup);
assert!(
!contains_subsequence(&result.phonemes, hundred_ordinal),
"1000000º should not be built from the hundredth root",
);
}
#[test]
fn ordinals_dutch() {
run_ipa_table("nl", "nl_dict", &[
("1e", "ˈɪːrstə"),
("3e", "dˈɛrdə"),
]);
}
#[test]
fn ordinals_german_dot() {
run_ipa_table("de", "de_dict", &[
("1.", "ˈeːɾstə"),
("3.", "dɾˈɪtə"),
("20.", "tsvˈantsɪçtə"),
("21.", "ˌaɪn ʊnttsvˈantsɪçtə"),
]);
}
#[test]
fn cardinals_1234567() {
let cases: &[(&str, &str, &str, &str)] = &[
("en", "en_dict",
"wˈɒn mˈɪliən tˈuːhˈʌndɹɪdən θˈɜːti fˈɔː θˈaʊzənd fˈaɪvhˈʌndɹɪdən sˈɪksti sˈɛvən",
"wˈɒn mˈɪliən tˈuːhˈʌndɹɪdən θˈɜːti fˈɔː θˈaʊzənd fˈaɪvhˈʌndɹɪdən sˈɪksti sˈɛvən"),
("es", "es_dict",
"ˈunmiʝˈon dosθjˈentos tɾˈeɪntaikwˈatɾo mˈil kinjˈɛntos sɛsˈɛntaisjˈete",
"ˈunmiʝˈon dosθjˈentos tɾˌeɪntaikwˈatɾo mˈil kinjˈɛntos sɛsˌɛntaisjˈete"),
("fr", "fr_dict",
"œ̃ miljɔ̃ døsɑ̃ tʁɑ̃tkatʁ mil sɛ̃ksɑ̃ swasɑ̃tsˈɛt",
"œ̃ miljˈɔ̃ døsɑ̃ tʁɑ̃tkatʁ mˈil sɛ̃ksɑ̃ swasɑ̃tsˈɛt"),
("de", "de_dict",
"ˈaɪnə mɪljˈoːn tsvˈaɪhˈʊndɜt fˈiːɾ ʊntdɾˈaɪsɪç tˈaʊzənt fˈʏnfhˈʊndɜt zˈiːbən ʊntzˈɛçtsɪç",
"ˈaɪnə mɪljˈoːn tsvˈaɪhˈʊndɜt fˈiːɾ ʊntdɾˈaɪsɪç tˈaʊzənt fˈynfhˈʊndɜt zˈiːbən ʊntzˈɛçtsɪç"),
("nl", "nl_dict",
"ˈeːn mˌiljun tʋˌeːhˌɔndərt vˌirɛndˌɛrtəx dˌœyzɛnt vˌɛɪfhˌɔndərt zˌeːvənɛnzˌɛstəx",
"ˈeːn mˌiljun tʋˈeːhˌɔndərt vˌirɛndˌɛrtəx dˌœyzɛnt vˈɛɪfhˌɔndərt zˌeːvənɛnzˌɛstəx"),
];
for &(lang, dict, expected, _oracle) in cases {
let dict_path = format!("espeak-ng-data/{dict}");
if !Path::new(&dict_path).exists() { continue; }
let t = Translator::new_default(lang).unwrap();
let ipa = t.text_to_ipa("1234567").unwrap();
assert_eq!(ipa, expected, "lang={lang} input=\"1234567\"");
}
}
#[test]
fn cardinals_english_billion_scale() {
let dict_path = "espeak-ng-data/en_dict";
if !Path::new(dict_path).exists() { return; }
let dict = Dictionary::load("en", Path::new("espeak-ng-data")).unwrap();
let grammar = LangOptions::for_lang("en").number_grammar;
let pronunciation = cardinal_pronunciation("1000000000", &dict, &grammar).unwrap();
let billion_lookup = lookup_num_phonemes(&dict, "_0M3");
let billion = trim_lookup(&billion_lookup);
assert!(!billion.is_empty(), "en_dict is missing _0M3");
let trimmed = &pronunciation.bytes[..pronunciation.trimmed_len()];
assert!(
trimmed.windows(billion.len()).any(|window| window == billion),
"1000000000 should include the billion scale phonemes",
);
}
#[test]
fn cardinals_french() {
let dict_path = "espeak-ng-data/fr_dict";
if !Path::new(dict_path).exists() { return; }
let t = Translator::new_default("fr").unwrap();
for input in ["1", "2", "3", "4", "20", "80", "87", "100", "101"] {
let ipa = t.text_to_ipa(input).unwrap();
assert!(!ipa.is_empty(), "fr {input} produced empty IPA");
assert!(!ipa.chars().any(|c| c.is_ascii_digit()),
"fr {input} has raw digits in IPA: {ipa}");
}
}
#[test]
fn french_cardinal_text_vigesimal() {
let cases = [
(0, "zéro"), (7, "sept"), (16, "seize"), (17, "dix-sept"),
(20, "vingt"), (21, "vingt et un"), (22, "vingt-deux"),
(31, "trente et un"), (60, "soixante"), (61, "soixante et un"),
(70, "soixante-dix"), (71, "soixante et onze"), (77, "soixante-dix-sept"),
(80, "quatre-vingts"), (81, "quatre-vingt-un"), (90, "quatre-vingt-dix"),
(91, "quatre-vingt-onze"), (99, "quatre-vingt-dix-neuf"),
(100, "cent"), (101, "cent un"), (200, "deux cents"),
(234, "deux cent trente-quatre"), (999, "neuf cent quatre-vingt-dix-neuf"),
];
for (n, want) in cases {
assert_eq!(fr_cardinal_text(n), want, "fr_cardinal_text({n})");
}
}
#[test]
fn french_ordinal_word_generation() {
let cases = [
("1", "er", "premier"), ("1", "re", "première"), ("1", "ère", "première"),
("2", "e", "deuxième"), ("2", "nd", "second"), ("2", "nde", "seconde"),
("3", "e", "troisième"), ("4", "e", "quatrième"), ("5", "e", "cinquième"),
("9", "e", "neuvième"), ("11", "e", "onzième"), ("17", "e", "dix-septième"),
("20", "e", "vingtième"), ("21", "e", "vingt et unième"),
("22", "e", "vingt-deuxième"), ("70", "e", "soixante-dixième"),
("71", "e", "soixante et onzième"), ("80", "e", "quatre-vingtième"),
("81", "e", "quatre-vingt-unième"), ("90", "e", "quatre-vingt-dixième"),
("99", "e", "quatre-vingt-dix-neuvième"), ("100", "e", "centième"),
("200", "e", "deux centième"), ("234", "e", "deux cent trente-quatrième"),
("1000", "e", "millième"),
];
for (d, suf, want) in cases {
assert_eq!(french_ordinal_word(d, suf).as_deref(), Some(want), "{d}{suf}");
}
assert_eq!(french_ordinal_word("0", "e"), None);
assert_eq!(french_ordinal_word("5000", "e"), None);
}
#[test]
fn portuguese_cardinal_generation() {
let cases = [
("0", "zero"), ("1", "um"), ("3", "três"), ("5", "cinco"),
("15", "quinze"), ("16", "dezasseis"), ("21", "vinte e um"),
("100", "cem"), ("101", "cento e um"), ("123", "cento e vinte e três"),
("200", "duzentos"), ("234", "duzentos e trinta e quatro"),
("999", "novecentos e noventa e nove"),
("1000", "mil"), ("1001", "mil e um"), ("1100", "mil e cem"),
("1120", "mil cento e vinte"), ("1500", "mil e quinhentos"), ("2000", "dois mil"), ("2020", "dois mil e vinte"),
("2234", "dois mil duzentos e trinta e quatro"),
("100000", "cem mil"),
("1000000", "um milhão"), ("2000000", "dois milhões"),
("1500000", "um milhão e quinhentos mil"),
("2000001", "dois milhões e um"),
];
for (digits, want) in cases {
assert_eq!(portuguese_cardinal_word(digits).as_deref(), Some(want), "pt {digits}");
}
assert_eq!(portuguese_cardinal_word("1000000000"), None);
}
#[test]
fn comma_decimal_languages_are_configured() {
for lang in ["sv", "pl", "cs", "ro", "el", "lv", "is", "ca", "eu", "sq",
"bs", "id", "vi", "az", "hy"] {
let g = NumberGrammar::for_lang(lang);
assert_eq!(g.decimal_separator, ',', "{lang} decimal");
assert_eq!(g.group_separator, Some('.'), "{lang} group");
assert!(g.space_group, "{lang} should allow space grouping too");
}
for lang in ["af", "ka", "kk"] {
assert_eq!(NumberGrammar::for_lang(lang).decimal_separator, '.', "{lang} decimal");
}
assert_eq!(NumberGrammar::for_lang("en").decimal_separator, '.');
assert_eq!(NumberGrammar::for_lang("en").group_separator, Some(','));
}
#[test]
fn roman_numeral_parsing() {
for (s, n) in [("I", 1), ("IV", 4), ("IX", 9), ("XIV", 14), ("XL", 40),
("XCIX", 99), ("MCMLXXXIV", 1984), ("MMXXIV", 2024)] {
assert_eq!(roman_value(s), Some(n), "{s}");
assert_eq!(to_roman(n), s, "to_roman({n})");
}
for s in ["IIII", "VX", "IC", "MIX", "DID", "MIMIC", "CIVIC", "", "A", "mix"] {
if s == "MIX" {
assert_eq!(roman_value(s), Some(1009));
} else {
assert_eq!(roman_value(s), None, "{s} should not be a valid numeral");
}
}
}
}