Skip to main content

atmos/kernel/
ime.rs

1// src/ime.rs - IME (Input Method Editor) Foundation
2#![allow(dead_code)]
3
4extern crate alloc;
5use alloc::string::String;
6use alloc::vec::Vec;
7
8const CONNECTION_DATA: &[u8] = include_bytes!("mozc_connection.bin");
9const CONNECTION_HEADER_LEN: usize = 14;
10const CONNECTION_COST_WEIGHT: i32 = 6;
11const MISSING_CONNECTION_INDEX: u16 = 0xFFFF;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ImeMode {
15    English,
16    Japanese,
17}
18
19/// FEP(全角/日本語)モードが有効かどうかを描画側から参照するためのグローバルフラグ。
20/// カーソル色の切り替え(半角=青系 / 全角=赤系)に使用する。
21static FEP_ACTIVE: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false);
22
23/// FEP(全角)モードが有効かどうかを返す。
24pub fn is_fep_active() -> bool {
25    FEP_ACTIVE.load(core::sync::atomic::Ordering::Relaxed)
26}
27
28/// カーソル描画色を返す。全角モード時は赤系、半角時は従来の青系。
29pub fn cursor_color() -> u32 {
30    if is_fep_active() {
31        0xFFE03C3C // 赤系(全角/FEP 有効)
32    } else {
33        0xFF756CE0 // 従来の青系(半角)
34    }
35}
36
37#[derive(Debug, Clone)]
38pub struct DictEntry {
39    pub word: String,
40    pub left_id: u16,
41    pub right_id: u16,
42    pub cost: i32,
43}
44
45#[derive(Clone)]
46struct PathState {
47    text: String,
48    cost: i32,
49    right_id: u16,
50}
51
52pub struct ImeState {
53    pub mode: ImeMode,
54    pub dictionary: alloc::collections::BTreeMap<String, Vec<DictEntry>>,
55    pub dict_loaded: bool,
56    pub composing: bool,
57    pub romaji_buf: String,
58    pub kana_buf: String,
59    pub converting: bool,
60    pub candidates: alloc::vec::Vec<String>,
61    pub selected_idx: usize,
62}
63
64impl Default for ImeState {
65    fn default() -> Self {
66        Self::new()
67    }
68}
69
70impl ImeState {
71    pub const fn new() -> Self {
72        Self {
73            mode: ImeMode::English,
74            dictionary: alloc::collections::BTreeMap::new(),
75            dict_loaded: false,
76            composing: false,
77            romaji_buf: String::new(), // This should use String::new() instead of String::new() in const fn if not possible. But String::new() is const since 1.39.
78            kana_buf: String::new(),
79            converting: false,
80            candidates: alloc::vec::Vec::new(),
81            selected_idx: 0,
82        }
83    }
84
85    pub fn load_dict(&mut self) {
86        if self.dict_loaded {
87            return;
88        }
89        crate::info!("[SYS] IME: Loading Mozc-based dictionary...");
90        let s = include_str!("mozc_dict.txt");
91        for line in s.lines() {
92            let line = line.trim();
93            if line.is_empty() || line.starts_with('#') {
94                continue;
95            }
96
97            let mut cols = line.split('\t');
98            let Some(yomi) = cols.next() else {
99                continue;
100            };
101            let Some(left_id) = parse_u16(cols.next()) else {
102                continue;
103            };
104            let Some(right_id) = parse_u16(cols.next()) else {
105                continue;
106            };
107            let Some(cost) = parse_i32(cols.next()) else {
108                continue;
109            };
110            let Some(word) = cols.next() else {
111                continue;
112            };
113
114            if yomi.is_empty() || word.is_empty() {
115                continue;
116            }
117
118            self.dictionary
119                .entry(String::from(yomi))
120                .or_default()
121                .push(DictEntry {
122                    word: String::from(word),
123                    left_id,
124                    right_id,
125                    cost,
126                });
127        }
128        self.dict_loaded = true;
129        crate::info!(
130            "IME: Loaded Mozc-based dictionary with {} readings",
131            self.dictionary.len()
132        );
133    }
134
135    pub fn toggle_mode(&mut self, to_japanese: bool) {
136        self.load_dict(); // Ensure dict is loaded on first use
137
138        if to_japanese {
139            self.mode = ImeMode::Japanese;
140        } else {
141            self.mode = ImeMode::English;
142        }
143        FEP_ACTIVE.store(
144            self.mode == ImeMode::Japanese,
145            core::sync::atomic::Ordering::Relaxed,
146        );
147        self.clear();
148    }
149
150    pub fn clear(&mut self) {
151        self.romaji_buf.clear();
152        self.kana_buf.clear();
153        self.composing = false;
154        self.converting = false;
155        self.candidates.clear();
156        self.selected_idx = 0;
157    }
158
159    pub fn input_char(&mut self, c: char) {
160        if self.mode == ImeMode::English {
161            return;
162        }
163
164        // ローマ字入力対象: 英字 + IME テーブルに含まれる記号
165        const ROMAJI_SYMBOLS: &[char] = &[
166            '-', ',', '.', '/', '!', '?', '[', ']', '{', '}', '(', ')', '~',
167        ];
168        if c.is_ascii_alphabetic() || ROMAJI_SYMBOLS.contains(&c) {
169            self.romaji_buf.push(c.to_ascii_lowercase());
170            self.composing = true;
171            self.convert_romaji();
172        } else {
173            self.kana_buf.push(c);
174            self.composing = true;
175        }
176    }
177
178    pub fn backspace(&mut self) {
179        if !self.romaji_buf.is_empty() {
180            self.romaji_buf.pop();
181            self.convert_romaji();
182        } else if !self.kana_buf.is_empty() {
183            self.kana_buf.pop();
184        }
185        if self.kana_buf.is_empty() && self.romaji_buf.is_empty() {
186            self.composing = false;
187            self.converting = false;
188            self.candidates.clear();
189            self.selected_idx = 0;
190        }
191    }
192
193    fn convert_romaji(&mut self) {
194        let table = [
195            // ---- 拗音(3文字 → 先に判定) ----
196            ("kya", "きゃ"),
197            ("kyu", "きゅ"),
198            ("kyo", "きょ"),
199            ("sha", "しゃ"),
200            ("shu", "しゅ"),
201            ("sho", "しょ"),
202            ("sya", "しゃ"),
203            ("syu", "しゅ"),
204            ("syo", "しょ"),
205            ("shi", "し"),
206            ("cha", "ちゃ"),
207            ("chu", "ちゅ"),
208            ("cho", "ちょ"),
209            ("chi", "ち"),
210            ("tya", "ちゃ"),
211            ("tyu", "ちゅ"),
212            ("tyo", "ちょ"),
213            ("tsu", "つ"),
214            ("tu", "つ"),
215            ("nya", "にゃ"),
216            ("nyu", "にゅ"),
217            ("nyo", "にょ"),
218            ("hya", "ひゃ"),
219            ("hyu", "ひゅ"),
220            ("hyo", "ひょ"),
221            ("mya", "みゃ"),
222            ("myu", "みゅ"),
223            ("myo", "みょ"),
224            ("rya", "りゃ"),
225            ("ryu", "りゅ"),
226            ("ryo", "りょ"),
227            ("gya", "ぎゃ"),
228            ("gyu", "ぎゅ"),
229            ("gyo", "ぎょ"),
230            ("jya", "じゃ"),
231            ("jyu", "じゅ"),
232            ("jyo", "じょ"),
233            ("zya", "じゃ"),
234            ("zyu", "じゅ"),
235            ("zyo", "じょ"),
236            ("dya", "ぢゃ"),
237            ("dyu", "ぢゅ"),
238            ("dyo", "ぢょ"),
239            ("bya", "びゃ"),
240            ("byu", "びゅ"),
241            ("byo", "びょ"),
242            ("pya", "ぴゃ"),
243            ("pyu", "ぴゅ"),
244            ("pyo", "ぴょ"),
245            // 小書き仮名 (l- prefix)
246            ("la", "ぁ"),
247            ("li", "ぃ"),
248            ("lu", "ぅ"),
249            ("le", "ぇ"),
250            ("lo", "ぉ"),
251            ("lya", "ゃ"),
252            ("lyu", "ゅ"),
253            ("lyo", "ょ"),
254            ("lwa", "ゎ"),
255            ("ltu", "っ"),
256            ("ltsu", "っ"),
257            ("xa", "ぁ"),
258            ("xi", "ぃ"),
259            ("xu", "ぅ"),
260            ("xe", "ぇ"),
261            ("xo", "ぉ"),
262            ("xya", "ゃ"),
263            ("xyu", "ゅ"),
264            ("xyo", "ょ"),
265            ("xtu", "っ"),
266            ("xtsu", "っ"),
267            // ---- 母音 ----
268            ("a", "あ"),
269            ("i", "い"),
270            ("u", "う"),
271            ("e", "え"),
272            ("o", "お"),
273            // ---- か行 ----
274            ("ka", "か"),
275            ("ki", "き"),
276            ("ku", "く"),
277            ("ke", "け"),
278            ("ko", "こ"),
279            // ---- さ行 ----
280            ("sa", "さ"),
281            ("si", "し"),
282            ("su", "す"),
283            ("se", "せ"),
284            ("so", "そ"),
285            // ---- た行 ----
286            ("ta", "た"),
287            ("ti", "ち"),
288            ("te", "て"),
289            ("to", "と"),
290            // ---- な行 ----
291            ("na", "な"),
292            ("ni", "に"),
293            ("nu", "ぬ"),
294            ("ne", "ね"),
295            ("no", "の"),
296            // ---- は行 ----
297            ("ha", "は"),
298            ("hi", "ひ"),
299            ("fu", "ふ"),
300            ("hu", "ふ"),
301            ("he", "へ"),
302            ("ho", "ほ"),
303            // ---- ま行 ----
304            ("ma", "ま"),
305            ("mi", "み"),
306            ("mu", "む"),
307            ("me", "め"),
308            ("mo", "も"),
309            // ---- や行 ----
310            ("ya", "や"),
311            ("yu", "ゆ"),
312            ("yo", "よ"),
313            // ---- ら行 ----
314            ("ra", "ら"),
315            ("ri", "り"),
316            ("ru", "る"),
317            ("re", "れ"),
318            ("ro", "ろ"),
319            // ---- わ行・ん ----
320            ("wa", "わ"),
321            ("wi", "ゐ"),
322            ("we", "ゑ"),
323            ("wo", "を"),
324            ("wha", "うぁ"),
325            ("whi", "うぃ"),
326            ("whe", "うぇ"),
327            ("who", "うぉ"),
328            // ---- が行 ----
329            ("ga", "が"),
330            ("gi", "ぎ"),
331            ("gu", "ぐ"),
332            ("ge", "げ"),
333            ("go", "ご"),
334            // ---- ざ行 ----
335            ("za", "ざ"),
336            ("ji", "じ"),
337            ("zi", "じ"),
338            ("zu", "ず"),
339            ("ze", "ぜ"),
340            ("zo", "ぞ"),
341            ("ja", "じゃ"),
342            ("ju", "じゅ"),
343            ("jo", "じょ"),
344            // ---- だ行 ----
345            ("da", "だ"),
346            ("di", "ぢ"),
347            ("du", "づ"),
348            ("de", "で"),
349            ("do", "ど"),
350            // ---- ば行 ----
351            ("ba", "ば"),
352            ("bi", "び"),
353            ("bu", "ぶ"),
354            ("be", "べ"),
355            ("bo", "ぼ"),
356            // ---- ぱ行 ----
357            ("pa", "ぱ"),
358            ("pi", "ぴ"),
359            ("pu", "ぷ"),
360            ("pe", "ぺ"),
361            ("po", "ぽ"),
362            // ---- ヴ行(外来語) ----
363            ("va", "ヴぁ"),
364            ("vi", "ヴぃ"),
365            ("vu", "ヴ"),
366            ("ve", "ヴぇ"),
367            ("vo", "ヴぉ"),
368            // ---- ファ行(外来語) ----
369            ("fa", "ふぁ"),
370            ("fi", "ふぃ"),
371            ("fe", "ふぇ"),
372            ("fo", "ふぉ"),
373            // ---- ティ・ディ(外来語) ----
374            ("thi", "てぃ"),
375            ("dhi", "でぃ"),
376            ("dhu", "どぅ"),
377            ("dho", "どぉ"),
378            ("twu", "とぅ"),
379            // ---- 記号・句読点 ----
380            (",", "、"),
381            (".", "。"),
382            ("/", "・"),
383            ("!", "!"),
384            ("?", "?"),
385            ("[", "「"),
386            ("]", "」"),
387            ("{", "『"),
388            ("}", "』"),
389            ("(", "("),
390            (")", ")"),
391            // ---- 長音符・その他 ----
392            ("-", "ー"),
393            ("~", "〜"),
394        ];
395
396        let mut temp_kana = String::new();
397        let mut romaji = self.romaji_buf.clone();
398
399        loop {
400            let mut matched = false;
401            for (rom, kana) in table.iter() {
402                if romaji.starts_with(rom) {
403                    temp_kana.push_str(kana);
404                    romaji = String::from(romaji.get(rom.len()..).unwrap_or(""));
405                    matched = true;
406                    break;
407                }
408            }
409            if !matched {
410                // 促音化(っ): 同じ子音の連続。ただし 'n' は撥音(ん)扱いなので除外。
411                if romaji.len() > 1
412                    && romaji.as_bytes()[0] != b'n'
413                    && romaji.as_bytes()[0] == romaji.as_bytes()[1]
414                {
415                    temp_kana.push('っ');
416                    romaji = String::from(romaji.get(1..).unwrap_or(""));
417                } else if starts_with_n_before_consonant(&romaji) {
418                    temp_kana.push('ん');
419                    romaji = String::from(romaji.get(1..).unwrap_or(""));
420                } else {
421                    break;
422                }
423            }
424        }
425
426        if !temp_kana.is_empty() {
427            self.kana_buf.push_str(&temp_kana);
428            self.romaji_buf = romaji;
429        }
430    }
431
432    fn flush_pending_romaji(&mut self) {
433        if self.romaji_buf == "n" {
434            self.kana_buf.push('ん');
435            self.romaji_buf.clear();
436        }
437    }
438
439    pub fn handle_space(&mut self) -> bool {
440        if !self.composing {
441            return false;
442        }
443
444        self.flush_pending_romaji();
445
446        if !self.converting {
447            // First time pressing space -> start conversion
448            self.candidates.clear();
449
450            self.build_conversion_candidates();
451
452            // 変換不能でも入力を確定できるよう、元のひらがなを候補に追加
453            let hiragana = self.kana_buf.clone();
454            if !self.candidates.contains(&hiragana) {
455                self.candidates.push(hiragana);
456            }
457
458            // カタカナ変換結果を候補に追加
459            let katakana = to_katakana(&self.kana_buf);
460            if !self.candidates.contains(&katakana) {
461                self.candidates.push(katakana);
462            }
463
464            self.converting = true;
465            self.selected_idx = 0;
466        } else {
467            // Already converting -> next candidate
468            if !self.candidates.is_empty() {
469                self.selected_idx = (self.selected_idx + 1) % self.candidates.len();
470            }
471        }
472        true
473    }
474
475    fn build_conversion_candidates(&mut self) {
476        const BEAM_WIDTH: usize = 8;
477        const MAX_CANDIDATES: usize = 12;
478        const UNKNOWN_COST: i32 = 12000;
479        const SEGMENT_COST: i32 = 1000;
480
481        let input = self.kana_buf.as_str();
482        let positions = byte_positions(input);
483        if positions.len() <= 1 {
484            return;
485        }
486
487        let mut lattice: Vec<Vec<PathState>> = Vec::new();
488        for _ in 0..positions.len() {
489            lattice.push(Vec::new());
490        }
491        lattice[0].push(PathState {
492            text: String::new(),
493            cost: 0,
494            right_id: 0,
495        });
496
497        for start_idx in 0..positions.len() - 1 {
498            if lattice[start_idx].is_empty() {
499                continue;
500            }
501
502            let start_byte = positions[start_idx];
503            let states = lattice[start_idx].clone();
504
505            for end_idx in start_idx + 1..positions.len() {
506                let end_byte = positions[end_idx];
507                let yomi = input.get(start_byte..end_byte).unwrap_or("");
508
509                if let Some(entries) = self.dictionary.get(yomi) {
510                    for state in states.iter() {
511                        for entry in entries {
512                            let mut text = state.text.clone();
513                            text.push_str(&entry.word);
514                            let cost = state.cost
515                                + adjusted_entry_cost(yomi, entry)
516                                + transition_cost(state.right_id, entry.left_id)
517                                + SEGMENT_COST;
518                            push_beam(
519                                &mut lattice[end_idx],
520                                PathState {
521                                    text,
522                                    cost,
523                                    right_id: entry.right_id,
524                                },
525                                BEAM_WIDTH,
526                            );
527                        }
528                    }
529                }
530            }
531
532            let next_byte = positions[start_idx + 1];
533            let unknown = input.get(start_byte..next_byte).unwrap_or("");
534            for state in states.iter() {
535                let mut text = state.text.clone();
536                text.push_str(unknown);
537                push_beam(
538                    &mut lattice[start_idx + 1],
539                    PathState {
540                        text,
541                        cost: state.cost + UNKNOWN_COST,
542                        right_id: 0,
543                    },
544                    BEAM_WIDTH,
545                );
546            }
547        }
548
549        let Some(final_states) = lattice.last_mut() else {
550            return;
551        };
552        final_states.sort_by_key(|state| state.cost);
553
554        for state in final_states.iter() {
555            if self.candidates.len() >= MAX_CANDIDATES {
556                break;
557            }
558            if !self.candidates.contains(&state.text) {
559                self.candidates.push(state.text.clone());
560            }
561        }
562    }
563
564    pub fn handle_enter(&mut self) -> Option<String> {
565        self.flush_pending_romaji();
566
567        if self.converting {
568            // 候補が空/範囲外でもパニックしないよう get で安全に取り出す。
569            let res = self
570                .candidates
571                .get(self.selected_idx)
572                .cloned()
573                .unwrap_or_else(|| self.kana_buf.clone());
574            self.clear();
575            Some(res)
576        } else if self.composing {
577            let res = self.kana_buf.clone();
578            self.clear();
579            Some(res)
580        } else {
581            None
582        }
583    }
584
585    /// Escape キー: 変換中なら変換をキャンセルしてひらがなに戻る。未変換なら全クリア。
586    pub fn cancel(&mut self) {
587        if self.converting {
588            self.converting = false;
589            self.candidates.clear();
590            self.selected_idx = 0;
591            // kana_buf はそのまま残す(ひらがな入力状態に戻る)
592        } else {
593            self.clear();
594        }
595    }
596
597    /// 数字キー(1〜9)で候補を直接選択して確定。確定テキストを返す。
598    pub fn select_candidate_by_number(&mut self, n: usize) -> Option<String> {
599        if !self.converting || self.candidates.is_empty() {
600            return None;
601        }
602        let idx = n.saturating_sub(1); // 1→0, 2→1, ...
603        if idx < self.candidates.len() {
604            let res = self.candidates[idx].clone();
605            self.clear();
606            Some(res)
607        } else {
608            None
609        }
610    }
611
612    pub fn get_display_text(&self) -> String {
613        if self.converting && !self.candidates.is_empty() {
614            self.candidates[self.selected_idx].clone()
615        } else {
616            self.kana_buf.clone() + &self.romaji_buf
617        }
618    }
619}
620
621/// IME の自己テスト。ローマ字→かな変換と、かな→漢字変換(Mozc 辞書+ラティス)を検証する。
622/// 起動時に `IME_SELFTEST: PASS n/n` を出す(CI スモークの判定マーカー)。
623pub fn selftest() -> (usize, usize) {
624    // ローマ字 → ひらがな(辞書不要・決定的)。
625    let kana_cases: &[(&str, &str)] = &[
626        ("nihon", "にほん"),
627        ("konnichiha", "こんにちは"),
628        ("gakkou", "がっこう"),
629        ("denwa", "でんわ"),
630        ("kanji", "かんじ"),
631        ("kya", "きゃ"),
632        ("sha", "しゃ"),
633        ("chi", "ち"),
634        ("tte", "って"),
635        ("shinbun", "しんぶん"),
636        ("wo", "を"),
637        ("sakka", "さっか"),
638        ("zasshi", "ざっし"),
639        ("annai", "あんない"),
640    ];
641    // かな(ローマ字入力経由)→ 漢字。候補に期待語が含まれるか。
642    let conv_cases: &[(&str, &str)] = &[
643        ("nihon", "日本"),
644        ("denwa", "電話"),
645        ("gakkou", "学校"),
646        ("kanji", "漢字"),
647    ];
648
649    let mut passed = 0;
650    let total = kana_cases.len() + conv_cases.len();
651
652    // 辞書ロードは高コスト(約 7.8 万エントリ)なので 1 度だけ行い、ケース間は clear() で再利用する。
653    let mut ime = ImeState::new();
654    ime.toggle_mode(true); // 日本語モード(辞書ロード込み・1 回のみ)
655
656    for (rom, kana) in kana_cases {
657        ime.clear();
658        for c in rom.chars() {
659            ime.input_char(c);
660        }
661        ime.flush_pending_romaji();
662        if &ime.kana_buf == kana {
663            passed += 1;
664        } else {
665            crate::println!(
666                "IME_SELFTEST FAIL: `{}` => `{}` (want `{}`)",
667                rom,
668                ime.kana_buf,
669                kana
670            );
671        }
672    }
673
674    for (rom, kanji) in conv_cases {
675        ime.clear();
676        for c in rom.chars() {
677            ime.input_char(c);
678        }
679        ime.handle_space();
680        if ime.candidates.iter().any(|c| c == kanji) {
681            passed += 1;
682        } else {
683            crate::println!(
684                "IME_SELFTEST FAIL: convert `{}` missing `{}` (got {:?})",
685                rom,
686                kanji,
687                ime.candidates
688            );
689        }
690    }
691
692    (passed, total)
693}
694
695fn parse_u16(s: Option<&str>) -> Option<u16> {
696    s?.parse::<u16>().ok()
697}
698
699fn parse_i32(s: Option<&str>) -> Option<i32> {
700    s?.parse::<i32>().ok()
701}
702
703fn byte_positions(s: &str) -> Vec<usize> {
704    let mut positions = Vec::new();
705    positions.push(0);
706    for (idx, _) in s.char_indices().skip(1) {
707        positions.push(idx);
708    }
709    positions.push(s.len());
710    positions
711}
712
713fn transition_cost(prev_right_id: u16, left_id: u16) -> i32 {
714    mozc_connection_cost(prev_right_id, left_id)
715        .map(|cost| cost / CONNECTION_COST_WEIGHT)
716        .unwrap_or_else(|| {
717            if prev_right_id == 0 || left_id == 0 {
718                0
719            } else if prev_right_id == left_id {
720                40
721            } else {
722                220
723            }
724        })
725}
726
727fn mozc_connection_cost(prev_right_id: u16, left_id: u16) -> Option<i32> {
728    if CONNECTION_DATA.len() < CONNECTION_HEADER_LEN || &CONNECTION_DATA[..8] != b"MOZCCON1" {
729        return None;
730    }
731
732    let context_count = read_u16(CONNECTION_DATA, 8)? as usize;
733    let right_count = read_u16(CONNECTION_DATA, 10)? as usize;
734    let left_count = read_u16(CONNECTION_DATA, 12)? as usize;
735    let prev_right_id = prev_right_id as usize;
736    let left_id = left_id as usize;
737
738    if prev_right_id >= context_count || left_id >= context_count {
739        return None;
740    }
741
742    let right_lookup_offset = CONNECTION_HEADER_LEN;
743    let left_lookup_offset = right_lookup_offset + context_count * 2;
744    let matrix_offset = left_lookup_offset + context_count * 2;
745    let right_index = read_u16(CONNECTION_DATA, right_lookup_offset + prev_right_id * 2)?;
746    let left_index = read_u16(CONNECTION_DATA, left_lookup_offset + left_id * 2)?;
747
748    if right_index == MISSING_CONNECTION_INDEX || left_index == MISSING_CONNECTION_INDEX {
749        return None;
750    }
751
752    let right_index = right_index as usize;
753    let left_index = left_index as usize;
754    if right_index >= right_count || left_index >= left_count {
755        return None;
756    }
757
758    let cost_offset = matrix_offset + (right_index * left_count + left_index) * 2;
759    read_u16(CONNECTION_DATA, cost_offset).map(|cost| cost as i32)
760}
761
762fn adjusted_entry_cost(yomi: &str, entry: &DictEntry) -> i32 {
763    let mut cost = entry.cost;
764    let yomi_len = yomi.chars().count() as i32;
765    if entry.word.chars().all(is_hiragana) && !prefers_hiragana(yomi) {
766        cost += 2500;
767    }
768    if yomi_len == 1 && !entry.word.chars().all(is_hiragana) {
769        cost += 1500;
770    }
771    if yomi_len > 1 && entry.word.chars().all(is_katakana) {
772        cost += 1000;
773    }
774    cost -= (yomi_len - 1).max(0) * 1000;
775    cost
776}
777
778fn push_beam(beam: &mut Vec<PathState>, state: PathState, limit: usize) {
779    if let Some(existing) = beam
780        .iter_mut()
781        .find(|candidate| candidate.text == state.text)
782    {
783        if state.cost < existing.cost {
784            *existing = state;
785        }
786    } else {
787        beam.push(state);
788    }
789
790    beam.sort_by_key(|candidate| candidate.cost);
791    if beam.len() > limit {
792        beam.truncate(limit);
793    }
794}
795
796fn starts_with_n_before_consonant(s: &str) -> bool {
797    if !s.starts_with('n') || s.len() < 2 {
798        return false;
799    }
800
801    let next = s.as_bytes()[1] as char;
802    // 母音・y 以外の子音が続けば撥音「ん」。`nn` も撥音(n を 1 つ消費し、
803    // 残り 1 つは次の母音と結合: konnichiha → こんにちは)。
804    !matches!(next, 'a' | 'i' | 'u' | 'e' | 'o' | 'y')
805}
806
807fn read_u16(bytes: &[u8], offset: usize) -> Option<u16> {
808    if offset + 1 >= bytes.len() {
809        None
810    } else {
811        Some(u16::from_le_bytes([bytes[offset], bytes[offset + 1]]))
812    }
813}
814
815fn is_hiragana(c: char) -> bool {
816    ('\u{3041}'..='\u{3096}').contains(&c)
817}
818
819fn is_katakana(c: char) -> bool {
820    ('\u{30A1}'..='\u{30FA}').contains(&c) || c == 'ー'
821}
822
823fn prefers_hiragana(s: &str) -> bool {
824    matches!(
825        s,
826        "は" | "が"
827            | "を"
828            | "に"
829            | "へ"
830            | "と"
831            | "で"
832            | "も"
833            | "の"
834            | "や"
835            | "ね"
836            | "よ"
837            | "です"
838            | "ます"
839            | "でした"
840            | "ません"
841            | "ない"
842            | "いい"
843    )
844}
845
846// ひらがなをカタカナに変換するヘルパー関数
847fn to_katakana(s: &str) -> String {
848    let mut res = String::new();
849    for c in s.chars() {
850        if ('\u{3041}'..='\u{3096}').contains(&c) {
851            if let Some(kc) = core::char::from_u32(c as u32 + 0x60) {
852                res.push(kc);
853            } else {
854                res.push(c);
855            }
856        } else {
857            res.push(c);
858        }
859    }
860    res
861}