use std::borrow::Cow;
#[rustfmt::skip]
const HALF_KANA_TO_FULL: [char; 60] = [
'・', 'ヲ', 'ァ', 'ィ', 'ゥ', 'ェ', 'ォ', 'ャ',
'ュ', 'ョ', 'ッ', 'ー', 'ア', 'イ', 'ウ', 'エ',
'オ', 'カ', 'キ', 'ク', 'ケ', 'コ', 'サ', 'シ',
'ス', 'セ', 'ソ', 'タ', 'チ', 'ツ', 'テ', 'ト',
'ナ', 'ニ', 'ヌ', 'ネ', 'ノ', 'ハ', 'ヒ', 'フ',
'ヘ', 'ホ', 'マ', 'ミ', 'ム', 'メ', 'モ', 'ヤ',
'ユ', 'ヨ', 'ラ', 'リ', 'ル', 'レ', 'ロ', 'ワ',
'ン', '゛', '゜', '\0',
];
#[rustfmt::skip]
const DAKUTEN: &[(char, char)] = &[
('ウ', 'ヴ'), ('カ', 'ガ'), ('キ', 'ギ'), ('ク', 'グ'), ('ケ', 'ゲ'),
('コ', 'ゴ'), ('サ', 'ザ'), ('シ', 'ジ'), ('ス', 'ズ'), ('セ', 'ゼ'),
('ソ', 'ゾ'), ('タ', 'ダ'), ('チ', 'ヂ'), ('ツ', 'ヅ'), ('テ', 'デ'),
('ト', 'ド'), ('ハ', 'バ'), ('ヒ', 'ビ'), ('フ', 'ブ'), ('ヘ', 'ベ'),
('ホ', 'ボ'),
];
#[rustfmt::skip]
const HANDAKUTEN: &[(char, char)] = &[
('ハ', 'パ'), ('ヒ', 'ピ'), ('フ', 'プ'), ('ヘ', 'ペ'), ('ホ', 'ポ'),
];
fn dakuten_compose(base: char, mark: char) -> Option<char> {
let table: &[(char, char)] = match mark {
'\u{FF9E}' => DAKUTEN,
'\u{FF9F}' => HANDAKUTEN,
_ => return None,
};
table
.iter()
.find(|(b, _)| *b == base)
.map(|(_, composed)| *composed)
}
fn halfwidth_kana_to_full(c: char) -> Option<char> {
let n = c as u32;
if (0xFF65..=0xFF9F).contains(&n) {
let idx = (n - 0xFF65) as usize;
let mapped = HALF_KANA_TO_FULL[idx];
if mapped == '\0' { None } else { Some(mapped) }
} else {
None
}
}
fn is_dakuten_or_handakuten(c: char) -> bool {
c == '\u{FF9E}' || c == '\u{FF9F}'
}
pub(crate) fn convert_width(input: &str) -> Cow<'_, str> {
let mut owned: Option<String> = None;
let mut chars = input.char_indices().peekable();
while let Some((idx, ch)) = chars.next() {
if let Some(base_full) = halfwidth_kana_to_full(ch) {
let out_char = if let Some(&(_, next_ch)) = chars.peek() {
if is_dakuten_or_handakuten(next_ch) {
if let Some(composed) = dakuten_compose(ch, next_ch) {
chars.next(); composed
} else {
base_full
}
} else {
base_full
}
} else {
base_full
};
if owned.is_none() {
owned = Some(input[..idx].to_owned());
}
owned.as_mut().unwrap().push(out_char);
continue;
}
if let Some(out_char) = convert_char(ch) {
if owned.is_none() {
owned = Some(input[..idx].to_owned());
}
owned.as_mut().unwrap().push(out_char);
} else if let Some(ref mut s) = owned {
s.push(ch);
}
}
match owned {
Some(s) => Cow::Owned(s),
None => Cow::Borrowed(input),
}
}
fn convert_char(c: char) -> Option<char> {
let n = c as u32;
if (0xFF01..=0xFF5E).contains(&n) {
return char::from_u32(n - 0xFEE0);
}
if c == '\u{3000}' {
return Some(' ');
}
None
}
fn full_katakana_to_half(c: char) -> Option<&'static str> {
match c {
'ァ' => Some("ァ"), 'ィ' => Some("ィ"), 'ゥ' => Some("ゥ"),
'ェ' => Some("ェ"), 'ォ' => Some("ォ"),
'ア' => Some("ア"), 'イ' => Some("イ"), 'ウ' => Some("ウ"),
'エ' => Some("エ"), 'オ' => Some("オ"),
'カ' => Some("カ"), 'ガ' => Some("ガ"),
'キ' => Some("キ"), 'ギ' => Some("ギ"),
'ク' => Some("ク"), 'グ' => Some("グ"),
'ケ' => Some("ケ"), 'ゲ' => Some("ゲ"),
'コ' => Some("コ"), 'ゴ' => Some("ゴ"),
'サ' => Some("サ"), 'ザ' => Some("ザ"),
'シ' => Some("シ"), 'ジ' => Some("ジ"),
'ス' => Some("ス"), 'ズ' => Some("ズ"),
'セ' => Some("セ"), 'ゼ' => Some("ゼ"),
'ソ' => Some("ソ"), 'ゾ' => Some("ゾ"),
'タ' => Some("タ"), 'ダ' => Some("ダ"),
'チ' => Some("チ"), 'ヂ' => Some("ヂ"),
'ッ' => Some("ッ"),
'ツ' => Some("ツ"), 'ヅ' => Some("ヅ"),
'テ' => Some("テ"), 'デ' => Some("デ"),
'ト' => Some("ト"), 'ド' => Some("ド"),
'ナ' => Some("ナ"), 'ニ' => Some("ニ"), 'ヌ' => Some("ヌ"),
'ネ' => Some("ネ"), 'ノ' => Some("ノ"),
'ハ' => Some("ハ"), 'バ' => Some("バ"), 'パ' => Some("パ"),
'ヒ' => Some("ヒ"), 'ビ' => Some("ビ"), 'ピ' => Some("ピ"),
'フ' => Some("フ"), 'ブ' => Some("ブ"), 'プ' => Some("プ"),
'ヘ' => Some("ヘ"), 'ベ' => Some("ベ"), 'ペ' => Some("ペ"),
'ホ' => Some("ホ"), 'ボ' => Some("ボ"), 'ポ' => Some("ポ"),
'マ' => Some("マ"), 'ミ' => Some("ミ"), 'ム' => Some("ム"),
'メ' => Some("メ"), 'モ' => Some("モ"),
'ャ' => Some("ャ"), 'ヤ' => Some("ヤ"),
'ュ' => Some("ュ"), 'ユ' => Some("ユ"),
'ョ' => Some("ョ"), 'ヨ' => Some("ヨ"),
'ラ' => Some("ラ"), 'リ' => Some("リ"), 'ル' => Some("ル"),
'レ' => Some("レ"), 'ロ' => Some("ロ"),
'ワ' => Some("ワ"), 'ヲ' => Some("ヲ"), 'ン' => Some("ン"),
'ヴ' => Some("ヴ"),
'・' => Some("・"), 'ー' => Some("ー"), _ => None,
}
}
pub(crate) fn apply_katakana_halfwidth_fold(input: &str) -> Cow<'_, str> {
let mut buf = String::new();
let mut changed = false;
for (i, c) in input.char_indices() {
match full_katakana_to_half(c) {
Some(half) => {
if !changed {
buf.reserve(input.len());
buf.push_str(&input[..i]);
changed = true;
}
buf.push_str(half);
}
None => {
if changed {
buf.push(c);
}
}
}
}
if changed { Cow::Owned(buf) } else { Cow::Borrowed(input) }
}
#[cfg(feature = "nfkc")]
pub(crate) fn apply_nfkc(input: Cow<'_, str>) -> Cow<'_, str> {
use unicode_normalization::UnicodeNormalization;
let s: &str = &input;
let normalized: String = s.nfkc().collect();
if normalized == s {
input
} else {
Cow::Owned(normalized)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fullwidth_ascii_converts_to_half() {
let result = convert_width("ABC123");
assert_eq!(result, "ABC123");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn fullwidth_space_converts() {
let result = convert_width(" ");
assert_eq!(result, " ");
}
#[test]
fn halfwidth_kana_base_converts() {
let result = convert_width("アイウエオ");
assert_eq!(result, "アイウエオ");
}
#[test]
fn halfwidth_kana_with_dakuten_composed() {
let input = "ガギグ";
let result = convert_width(input);
assert_eq!(result, "ガギグ");
assert_eq!(result.chars().count(), 3);
}
#[test]
fn halfwidth_kana_handakuten_composed() {
let input = "パピプ";
let result = convert_width(input);
assert_eq!(result, "パピプ");
assert_eq!(result.chars().count(), 3);
}
#[test]
fn halfwidth_dakuten_standalone_maps_to_fullwidth() {
let result = convert_width("゙");
assert_eq!(result, "゛");
}
#[test]
fn no_change_returns_borrowed() {
let result = convert_width("斉藤一郎");
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result, "斉藤一郎");
}
#[test]
fn plain_ascii_returns_borrowed() {
let result = convert_width("hello world");
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn mixed_fullwidth_and_cjk() {
let result = convert_width("ABC斉");
assert_eq!(result, "ABC斉");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn fullwidth_tilde_and_bracket() {
let result = convert_width("~");
assert_eq!(result, "~");
}
#[test]
fn full_katakana_to_halfwidth_basic() {
let result = apply_katakana_halfwidth_fold("アイウエオ");
assert_eq!(result, "アイウエオ");
assert!(matches!(result, Cow::Owned(_)));
}
#[test]
fn full_katakana_to_halfwidth_voiced() {
let result = apply_katakana_halfwidth_fold("ガギグ");
assert_eq!(result, "ガギグ");
assert_eq!(result.chars().count(), 6);
}
#[test]
fn full_katakana_to_halfwidth_semi_voiced() {
let result = apply_katakana_halfwidth_fold("パピプ");
assert_eq!(result, "パピプ");
assert_eq!(result.chars().count(), 6);
}
#[test]
fn full_katakana_to_halfwidth_vu() {
assert_eq!(apply_katakana_halfwidth_fold("ヴ"), "ヴ");
}
#[test]
fn full_katakana_no_halfwidth_form_passes_through() {
let input = "ヮヰヱヵヶ";
let result = apply_katakana_halfwidth_fold(input);
assert_eq!(result, input);
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn full_katakana_small_kana() {
assert_eq!(apply_katakana_halfwidth_fold("ァィゥェォャュョッ"), "ァィゥェォャュョッ");
}
#[test]
fn full_katakana_special_chars() {
assert_eq!(apply_katakana_halfwidth_fold("ヲンー・"), "ヲンー・");
}
#[test]
fn full_katakana_hiragana_passthrough() {
let input = "あいうえお";
let result = apply_katakana_halfwidth_fold(input);
assert!(matches!(result, Cow::Borrowed(_)));
assert_eq!(result, input);
}
#[test]
fn full_katakana_non_kana_passthrough() {
let input = "ABC斉藤123";
let result = apply_katakana_halfwidth_fold(input);
assert!(matches!(result, Cow::Borrowed(_)));
}
#[test]
fn full_katakana_round_trip_voiced() {
let half_in = "ガギグ";
let full = convert_width(half_in);
assert_eq!(full, "ガギグ");
let back = apply_katakana_halfwidth_fold(&full);
assert_eq!(back, half_in);
}
#[cfg(feature = "nfkc")]
#[test]
fn nfkc_converts_enclosed_cjk() {
let result = apply_nfkc(Cow::Borrowed("㈱"));
assert_eq!(result, "(株)");
}
#[cfg(feature = "nfkc")]
#[test]
fn nfkc_no_change_when_already_normalized() {
let result = apply_nfkc(Cow::Borrowed("斉藤"));
assert_eq!(result, "斉藤");
}
}