Skip to main content

msime_engine/zhuyin/
nine_key.rs

1//! 注音九键:十个数字键各承载几个注音符号(分组取自美国专利 US 6,009,444 的 FIG.1),用户按数字串打出一个音节的符号位置,再按声调键结束这个音节。一串数字对应所有长度相同、第 i 个符号落在第 i 个数字键上的合法带调音节,由词库自己的音节表决定哪些合法。
2
3use std::collections::{HashMap, HashSet};
4use std::sync::Arc;
5
6use super::layout::TONE_MARKS;
7
8/// 每个数字键上的注音符号,37 个符号各在且只在一个键上。分组固定照专利 FIG.1,不要调整。
9pub const KEYPAD: [(u8, &str); 10] = [
10    (b'1', "ㄅㄆㄇㄈ"),
11    (b'2', "ㄉㄊㄋㄌ"),
12    (b'3', "ㄍㄎㄏ"),
13    (b'4', "ㄐㄑㄒ"),
14    (b'5', "ㄓㄔㄕㄖ"),
15    (b'6', "ㄗㄘㄙ"),
16    (b'7', "ㄚㄛㄜㄝ"),
17    (b'8', "ㄧㄨㄩㄦ"),
18    (b'9', "ㄞㄟㄠㄡ"),
19    (b'0', "ㄢㄣㄤㄥ"),
20];
21
22/// 一到五声(ˉ ˊ ˇ ˋ ˙)的声调键,下标与 `layout::TONE_MARKS` 一致。空格同样是一声。
23pub const TONE_KEYS: [u8; 5] = *b"zxcvb";
24
25/// 一个音节最多的符号数(声母、介音、韵母各一),也就是一串数字的最大长度。
26pub const MAX_DIGITS: usize = 3;
27
28/// 空闲时认领的非字母键:数字开始一个音节;空格不在其中,空闲时由宿主自己打出空格。
29pub const IDLE_SYMBOLS: &str = "1234567890";
30
31/// 组字中、列表关闭时认领的非字母键:数字和作为一声的空格。
32pub const SYMBOLS: &str = "1234567890 ";
33
34/// 列表打开时认领的非字母键:数字关闭列表继续拼写,空格留给运行时选行。
35pub const LIST_OPEN_SYMBOLS: &str = "1234567890";
36
37/// 承载 `symbol` 的数字键;不是注音符号时为 `None`。
38pub fn digit(symbol: char) -> Option<u8> {
39    KEYPAD
40        .iter()
41        .find(|(_, symbols)| symbols.contains(symbol))
42        .map(|(key, _)| *key)
43}
44
45/// 声调键追加的调号:空格和 `z` 是一声(不加符号),`x c v b` 依次是 ˊ ˇ ˋ ˙;其他键为 `None`。
46pub fn tone_mark(key: u8) -> Option<&'static str> {
47    if key == b' ' {
48        return Some(TONE_MARKS[0]);
49    }
50    TONE_KEYS
51        .iter()
52        .position(|tone_key| *tone_key == key)
53        .map(|index| TONE_MARKS[index])
54}
55
56/// 词库音节表按「数字串 + 调号」分组后的索引。
57#[derive(Debug)]
58pub struct NineKeyIndex {
59    /// 键是数字串加调号(一声没有调号),值是这一组的带调音节,按字典序排好,保证结果确定。
60    readings: HashMap<String, Arc<[String]>>,
61    /// 任何一个音节数字串的全部前缀(含它自己),用来拒绝打不出任何音节的数字。
62    prefixes: HashSet<String>,
63}
64
65impl NineKeyIndex {
66    /// 从 `LanguageDictionary::syllables()` 建索引。末字符是 ˊ ˇ ˋ ˙ 之一时它是调号,否则是一声;其余字符逐个映射到数字键,有符号不在键盘上的音节被跳过。
67    pub fn new(syllables: Vec<String>) -> Self {
68        let mut groups: HashMap<String, Vec<String>> = HashMap::new();
69        let mut prefixes = HashSet::new();
70        for syllable in syllables {
71            let (symbols, mark) = split_tone(&syllable);
72            let Some(digits) = symbols.chars().map(digit).collect::<Option<Vec<u8>>>() else {
73                continue;
74            };
75            if digits.is_empty() || digits.len() > MAX_DIGITS {
76                continue;
77            }
78            let digits = String::from_utf8(digits).expect("keypad keys are ASCII digits");
79            for end in 1..=digits.len() {
80                prefixes.insert(digits[..end].to_owned());
81            }
82            groups
83                .entry(format!("{digits}{mark}"))
84                .or_default()
85                .push(syllable);
86        }
87        let readings = groups
88            .into_iter()
89            .map(|(code, mut readings)| {
90                readings.sort();
91                readings.dedup();
92                (code, Arc::from(readings))
93            })
94            .collect();
95        Self { readings, prefixes }
96    }
97
98    /// 数字串 `digits` 以调号 `mark` 结束时的全部合法带调音节;没有这样的音节时为 `None`。
99    pub fn readings(&self, digits: &[u8], mark: &str) -> Option<Arc<[String]>> {
100        let digits = std::str::from_utf8(digits).ok()?;
101        // 索引键由不超过 `MAX_DIGITS` 的数字和至多一个 Unicode 调号组成,超长拼接键必定未命中。
102        let mut buffer = [0; MAX_DIGITS + char::MAX.len_utf8()];
103        let length = digits.len().checked_add(mark.len())?;
104        let code = buffer.get_mut(..length)?;
105        code[..digits.len()].copy_from_slice(digits.as_bytes());
106        code[digits.len()..].copy_from_slice(mark.as_bytes());
107        self.readings.get(std::str::from_utf8(code).ok()?).cloned()
108    }
109
110    /// `digits` 是否还能拼成某个音节,也就是它是否为某个音节数字串的前缀。
111    pub fn accepts(&self, digits: &[u8]) -> bool {
112        std::str::from_utf8(digits).is_ok_and(|digits| self.prefixes.contains(digits))
113    }
114
115    /// 分组的数量,供测试核对索引规模。
116    #[cfg(test)]
117    fn group_count(&self) -> usize {
118        self.readings.len()
119    }
120}
121
122/// 把带调音节拆成符号和调号;一声没有调号。
123fn split_tone(syllable: &str) -> (&str, &str) {
124    match syllable.chars().next_back() {
125        Some(last) if TONE_MARKS[1..].iter().any(|mark| mark.starts_with(last)) => {
126            syllable.split_at(syllable.len() - last.len_utf8())
127        }
128        _ => (syllable, ""),
129    }
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use crate::zhuyin::layout::DACHEN;
136
137    fn owned(syllables: &[&str]) -> Vec<String> {
138        syllables
139            .iter()
140            .map(|syllable| (*syllable).to_owned())
141            .collect()
142    }
143
144    #[test]
145    fn every_bopomofo_is_on_exactly_the_patent_key() {
146        // 专利 FIG.1 的分组逐字核对。
147        let expected = [
148            ('1', "ㄅㄆㄇㄈ"),
149            ('2', "ㄉㄊㄋㄌ"),
150            ('3', "ㄍㄎㄏ"),
151            ('4', "ㄐㄑㄒ"),
152            ('5', "ㄓㄔㄕㄖ"),
153            ('6', "ㄗㄘㄙ"),
154            ('7', "ㄚㄛㄜㄝ"),
155            ('8', "ㄧㄨㄩㄦ"),
156            ('9', "ㄞㄟㄠㄡ"),
157            ('0', "ㄢㄣㄤㄥ"),
158        ];
159        for ((key, symbols), (expected_key, expected_symbols)) in KEYPAD.iter().zip(expected) {
160            assert_eq!(char::from(*key), expected_key);
161            assert_eq!(*symbols, expected_symbols);
162        }
163        // 37 个符号(大千键盘的全部符号)各落在唯一一个键上。
164        let mut seen = HashSet::new();
165        for (_, symbol, _) in DACHEN {
166            let key = digit(symbol).unwrap_or_else(|| panic!("{symbol} has no key"));
167            let holders = KEYPAD
168                .iter()
169                .filter(|(_, symbols)| symbols.contains(symbol))
170                .count();
171            assert_eq!(holders, 1, "{symbol}");
172            assert!(KEYPAD.iter().any(|(k, s)| *k == key && s.contains(symbol)));
173            seen.insert(symbol);
174        }
175        assert_eq!(seen.len(), 37);
176        let total: usize = KEYPAD
177            .iter()
178            .map(|(_, symbols)| symbols.chars().count())
179            .sum();
180        assert_eq!(total, 37);
181        assert_eq!(digit('a'), None);
182        assert_eq!(digit('ˇ'), None);
183    }
184
185    #[test]
186    fn tone_keys_map_to_marks_and_space_is_the_first_tone() {
187        assert_eq!(tone_mark(b' '), Some(""));
188        assert_eq!(tone_mark(b'z'), Some(""));
189        assert_eq!(tone_mark(b'x'), Some("ˊ"));
190        assert_eq!(tone_mark(b'c'), Some("ˇ"));
191        assert_eq!(tone_mark(b'v'), Some("ˋ"));
192        assert_eq!(tone_mark(b'b'), Some("˙"));
193        for key in *b"1234567890a6347" {
194            assert_eq!(tone_mark(key), None, "{}", char::from(key));
195        }
196    }
197
198    #[test]
199    fn symbol_strings_hold_digits_and_space_only_while_composing() {
200        assert_eq!(IDLE_SYMBOLS, "1234567890");
201        assert_eq!(SYMBOLS, "1234567890 ");
202        assert_eq!(LIST_OPEN_SYMBOLS, "1234567890");
203        for symbols in [IDLE_SYMBOLS, SYMBOLS, LIST_OPEN_SYMBOLS] {
204            assert!(!symbols.contains([',', '.', '/', ';', '-']));
205        }
206    }
207
208    #[test]
209    fn index_groups_syllables_by_digits_and_tone() {
210        let index = NineKeyIndex::new(owned(&[
211            "ㄋㄧˇ",
212            "ㄌㄧˇ",
213            "ㄋㄧ",
214            "ㄇㄚ˙",
215            "ㄇㄚ",
216            "ㄅㄚ",
217            "ㄏㄠˇ",
218            "ㄓㄨㄤ",
219            "ㄢ",
220        ]));
221        assert_eq!(
222            index.readings(b"28", "ˇ").as_deref(),
223            Some(&owned(&["ㄋㄧˇ", "ㄌㄧˇ"])[..])
224        );
225        // 一声不带调号,与带调的分在不同组。
226        assert_eq!(
227            index.readings(b"28", "").as_deref(),
228            Some(&owned(&["ㄋㄧ"])[..])
229        );
230        assert_eq!(
231            index.readings(b"17", "˙").as_deref(),
232            Some(&owned(&["ㄇㄚ˙"])[..])
233        );
234        assert_eq!(
235            index.readings(b"17", "").as_deref(),
236            Some(&owned(&["ㄅㄚ", "ㄇㄚ"])[..])
237        );
238        assert_eq!(
239            index.readings(b"580", "").as_deref(),
240            Some(&owned(&["ㄓㄨㄤ"])[..])
241        );
242        assert_eq!(
243            index.readings(b"0", "").as_deref(),
244            Some(&owned(&["ㄢ"])[..])
245        );
246        assert_eq!(index.readings(b"28", "ˋ"), None);
247        assert_eq!(index.readings(b"2", "ˇ"), None);
248        assert_eq!(index.group_count(), 7);
249    }
250
251    #[test]
252    fn index_reading_lookups_do_not_allocate_temporary_keys() {
253        let syllables = TONE_MARKS
254            .iter()
255            .map(|mark| format!("ㄓㄨㄤ{mark}"))
256            .collect::<Vec<_>>();
257        let index = NineKeyIndex::new(syllables.clone());
258        let ((), allocations) = crate::ime::personal_rerank::allocations::count(|| {
259            for (mark, expected) in TONE_MARKS.iter().zip(&syllables) {
260                let readings = index.readings(b"580", mark).unwrap();
261                assert_eq!(readings.as_ref(), std::slice::from_ref(expected));
262            }
263            assert!(index.readings(b"999", "ˇ").is_none());
264        });
265        assert_eq!(allocations, 0);
266    }
267
268    #[test]
269    fn index_reading_lookups_preserve_key_part_and_invalid_input_behavior() {
270        let index = NineKeyIndex::new(owned(&["ㄋㄧˇ"]));
271        let expected = index.readings(b"28", "ˇ").unwrap();
272        for (digits, mark) in [
273            (b"2".as_slice(), "8ˇ"),
274            ("28ˇ".as_bytes(), ""),
275            (b"".as_slice(), "28ˇ"),
276        ] {
277            let readings = index.readings(digits, mark).unwrap();
278            assert!(Arc::ptr_eq(&expected, &readings));
279        }
280        for (digits, mark) in [
281            (b"\xff".as_slice(), "ˇ"),
282            (b"28".as_slice(), "測試長調號"),
283            (b"2899999999".as_slice(), "ˇ"),
284            (b"".as_slice(), ""),
285        ] {
286            assert!(index.readings(digits, mark).is_none());
287        }
288    }
289
290    #[test]
291    fn index_accepts_only_prefixes_of_some_syllable() {
292        let index = NineKeyIndex::new(owned(&["ㄋㄧˇ", "ㄓㄨㄤ", "ㄢ"]));
293        for accepted in ["2", "28", "5", "58", "580", "0"] {
294            assert!(index.accepts(accepted.as_bytes()), "{accepted}");
295        }
296        for rejected in ["", "1", "29", "282", "5800", "00"] {
297            assert!(!index.accepts(rejected.as_bytes()), "{rejected}");
298        }
299    }
300
301    #[test]
302    fn index_skips_symbols_off_the_keypad() {
303        let index = NineKeyIndex::new(owned(&["nei", "ㄋㄧˇ", "ㄋㄧㄠㄢ"]));
304        assert_eq!(index.group_count(), 1);
305        assert!(!index.accepts(b"2890"));
306    }
307}