use log::{debug, warn};
use regex::Regex;
use std::{collections::HashMap, sync::LazyLock};
static TONED: LazyLock<HashMap<u32, String>> = LazyLock::new(load_data);
static TONELESS: LazyLock<HashMap<u32, String>> = LazyLock::new(load_data);
static DICT: LazyLock<HashMap<String, Vec<String>>> = LazyLock::new(load_dict);
fn match_indices_by_chars(text: &str, pattern: &str) -> Vec<(usize, String)> {
let matches = text.match_indices(pattern);
let mut result = Vec::new();
for (byte_idx, matched) in matches {
let char_idx = text[..byte_idx].chars().count();
result.push((char_idx, matched.to_string()));
}
result
}
fn load_dict() -> HashMap<String, Vec<String>> {
let content = include_str!("jyut6ping3.words.dict.yaml");
let parts: Vec<&str> = content.split("...").collect();
let word_lines = parts[1].trim().lines();
let mut res = HashMap::new();
for line in word_lines {
if line.trim().is_empty() || line.trim().starts_with('#') {
continue;
}
let columns: Vec<&str> = line.split('\t').collect();
if columns.len() == 2 {
let mut t: Vec<String> = columns[1].split(" ").map(|v| v.to_string()).collect();
let matches = match_indices_by_chars(columns[0], ",");
for m in matches {
t.insert(m.0, m.1);
}
let matches = match_indices_by_chars(columns[0], ":");
for m in matches {
t.insert(m.0, m.1);
}
if columns[0].chars().count() != t.len() {
}
res.insert(columns[0].to_owned(), t);
} else {
warn!("Skipping malformed line: {}", line);
}
}
res
}
fn load_data() -> HashMap<u32, String> {
let mut toned = HashMap::new();
let mut toneless = HashMap::new();
let contents = include_str!("jyutping_dictionary.json");
let table: HashMap<String, String> =
serde_json::from_str(contents).expect("Failed to parse JSON");
for (code, jyutping) in table {
let jyutping = jyutping.split_whitespace().next().unwrap_or("").to_string();
let code_int = u32::from_str_radix(&code, 16).expect("Failed to parse code as u32");
toned.insert(code_int, format!(" {} ", jyutping));
toneless.insert(code_int, format!(" {} ", &jyutping[..jyutping.len() - 1]));
}
if std::env::var("TONELESS").is_ok() {
toneless
} else {
toned
}
}
pub fn get_jyutping(text: &str, tone: bool) -> String {
if text.is_empty() {
return String::new();
}
let re = Regex::new(r"[\u{4e00}-\u{9fff}]+")
.map_err(|e| format!("Regex error: {}", e))
.unwrap();
if !re.is_match(text) {
return text.to_string();
}
let dict = if tone { &*TONED } else { &*TONELESS };
let mut converted = String::new();
for c in text.chars() {
let code = c as u32;
if let Some(jyutping) = dict.get(&code) {
converted.push_str(jyutping);
} else {
converted.push(c);
}
}
let result: String = converted
.split_whitespace()
.collect::<Vec<&str>>()
.join(" ");
result
}
pub fn get_jyutping_list(text: &str) -> Vec<(String, String)> {
let mut res = Vec::new();
let mut i = 0;
let chars: Vec<char> = text.chars().collect();
while i < chars.len() {
let mut found = false;
for len in (1..=chars.len() - i).rev() {
let slice: String = chars[i..i + len].iter().collect();
if let Some(jyutping) = DICT.get(&slice) {
debug!("use dict: {}: {:?}", slice, jyutping);
let text_single: Vec<String> = slice.chars().map(|v| v.to_string()).collect();
if jyutping.len() != text_single.len() {
warn!("char and jyutping size not match in dict, fallback to single character");
i += len;
} else {
for (ii, jy) in jyutping.iter().enumerate() {
res.push((text_single[ii].clone(), jy.to_string()));
}
i += len;
found = true;
}
break;
}
}
if !found {
let ch: String = chars[i].to_string();
let jyutping = get_jyutping(&ch, false);
res.push((ch, jyutping));
i += 1;
}
}
res
}