msime-engine 0.2.0

Input method engine for Chinese and other languages: quanpin, shuangpin, nine-key, wubi, zhuyin, Cantonese, Japanese, Korean and more, ranked over a word lattice with typo correction and learning from what the user picks. The engine of 水杉输入法 (Metasequoia IME).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
//! Display-only glosses and English completions (api-contract §1c, bridge.cpp:149-237, 554-572, 1138-1236).

use std::cell::RefCell;
use std::path::{Path, PathBuf};

use rusqlite::{Connection, OpenFlags, OptionalExtension};

use crate::assets;
use crate::diagnostics;
use crate::dictionary::english::{
    load_custom_translations, upsert_gloss, CustomTranslations, EnglishDictionary,
};
use crate::error::{EngineError, Result};
use crate::japanese::decoder::FileIdentity;
use crate::types::CandidateSource;

/// Senses shown beside a candidate; the rest of a dictionary gloss is cut (bridge.cpp:209).
const MAXIMUM_GLOSS_SENSES: usize = 2;
const FULLWIDTH_SENSE_DELIMITER: &str = ";";
const MAXIMUM_ENGLISH_COMPLETIONS: usize = 32;

/// ASCII letters only, `limit` 1..=32, lowercased; empty prefix gives nothing (`INVALID_ENGLISH_COMPLETION_*`, `ENGLISH_DICTIONARY_UNAVAILABLE`).
pub fn english_completions(resources: &str, prefix: &str, limit: usize) -> Result<Vec<String>> {
    if limit == 0 || limit > MAXIMUM_ENGLISH_COMPLETIONS {
        return Err(EngineError::invalid(
            diagnostics::INVALID_ENGLISH_COMPLETION_LIMIT,
        ));
    }
    if !prefix.bytes().all(|byte| byte.is_ascii_alphabetic()) {
        return Err(EngineError::invalid(
            diagnostics::INVALID_ENGLISH_COMPLETION_PREFIX,
        ));
    }
    if prefix.is_empty() {
        return Ok(Vec::new());
    }
    let dictionary = EnglishDictionary::open(
        &Path::new(resources).join(assets::ENGLISH_DICTIONARY),
        None,
        None,
    );
    if !dictionary.ready() {
        return Err(EngineError::failed(
            diagnostics::ENGLISH_DICTIONARY_UNAVAILABLE,
        ));
    }
    // `word` is the display spelling, which is what the C++ `WordItem::word` returned too.
    Ok(dictionary
        .query_prefix(&prefix.to_ascii_lowercase(), limit)
        .into_iter()
        .map(|item| item.word.into_string())
        .collect())
}

thread_local! {
    // 每个宿主查询线程只保留最近一个固定资源目录,缓存不会随输入内容无限增长。
    static ENGLISH_CORRECTION: std::cell::RefCell<Option<(std::path::PathBuf, EnglishDictionary)>> = const { std::cell::RefCell::new(None) };
}

/// 释义词库的缓存键:词库路径、词库文件身份,以及 `EnglishDictionary::open` 一并读进来的同目录 `custom_translations.txt` 的身份。
type GlossDictionaryKey = (PathBuf, Option<FileIdentity>, Option<FileIdentity>);

/// 读好的自定义译文和它的缓存键:文件路径与文件身份。
type CachedCustomTranslations = ((PathBuf, Option<FileIdentity>), CustomTranslations);

/// 离线释义库连接最多留几份:汉字、词语两份英文表,加上六种离线语言各一份。
const TARGET_GLOSS_CONNECTION_CAPACITY: usize = 8;

/// Windows 上 SQLite 打开文件不带 `FILE_SHARE_DELETE`,查询线程一直开着的词库会让卸载离线释义包、更新资源时删不掉文件,而 Windows 的翻译工作线程与输入法服务同寿命;那里照旧每次查完就关,只缓存读好的自定义译文。
const KEEP_GLOSS_DATABASES_OPEN: bool = !cfg!(windows);

thread_local! {
    // 打开候选释义时每个按键都要查一遍:原来每次都重新打开 msime-english.db、学到的释义库、汉字和词语释义库,再读两遍 custom_translations.txt,低端机上一键要好几毫秒。这里按路径加文件身份留着打开过的词库和读好的自定义译文:文件没换就复用,下载了新的离线释义、设置页改了自定义译文、换了资源包,文件身份一变下一次查询就重新打开,结果和每次都重新打开相同。每个来源只留最近一个路径(离线释义库按上面的上限),内存有上限。学到的释义库由后台写入,SQLite 在每次读事务开始时都会看到已提交的新内容,文件被整个替换时身份也会变。
    static PACKAGED_GLOSS_DICTIONARY: RefCell<Option<(GlossDictionaryKey, EnglishDictionary)>> = const { RefCell::new(None) };
    static LEARNED_GLOSS_DICTIONARY: RefCell<Option<(GlossDictionaryKey, EnglishDictionary)>> = const { RefCell::new(None) };
    static CUSTOM_TRANSLATIONS: RefCell<Option<CachedCustomTranslations>> = const { RefCell::new(None) };
    static TARGET_GLOSS_CONNECTIONS: RefCell<Vec<(PathBuf, FileIdentity, Connection)>> = const { RefCell::new(Vec::new()) };
}

/// 关掉调用线程留着的释义词库连接,丢掉读好的自定义译文(发布词库、学到的释义库、自定义译文、离线释义库四份缓存)。缓存跟着查询线程活着,在鸿蒙上就是 ArkTS 的 UI 线程,等于整个进程的寿命;键盘收起、关掉释义以后它们本来一直留着。宿主在键盘收起、内存吃紧时调用,下一次查询照常重新打开,结果不变。只清调用线程自己的那份。
pub fn release_gloss_caches() {
    PACKAGED_GLOSS_DICTIONARY.with_borrow_mut(|slot| *slot = None);
    LEARNED_GLOSS_DICTIONARY.with_borrow_mut(|slot| *slot = None);
    CUSTOM_TRANSLATIONS.with_borrow_mut(|slot| *slot = None);
    TARGET_GLOSS_CONNECTIONS.with_borrow_mut(Vec::clear);
}

/// 手机英文始终以原始按键为第一行,仅在需要时附加唯一纠错;不创建或改变输入会话。
pub fn english_corrections(resources: &str, raw: &str) -> Result<Vec<String>> {
    validate_mobile_english(raw)?;
    let mut candidates = vec![raw.to_owned()];
    with_correction_dictionary(resources, |dictionary| {
        if let Some(correction) = dictionary.correct_mobile_english(raw) {
            candidates.push(correction);
        }
    });
    Ok(candidates)
}

/// 手机英文的合并展示,最多 `limit` 行:有纠错时只有原文与纠错;原文已是完整的词(词库精确命中、固定词形或受保护的网络缩写)时只有原文;否则原文后接前缀补全,首字母随原文大写。布尔值为真当且仅当第二行是纠错。不创建或改变输入会话。
pub fn english_correction_display(
    resources: &str,
    raw: &str,
    limit: usize,
) -> Result<(Vec<String>, bool)> {
    if limit == 0 || limit > MAXIMUM_ENGLISH_COMPLETIONS {
        return Err(EngineError::invalid(
            diagnostics::INVALID_ENGLISH_COMPLETION_LIMIT,
        ));
    }
    validate_mobile_english(raw)?;
    // 缺词库时与纠错查询一样:固定词形照常恢复,其余只有原文。
    let mut display = (vec![raw.to_owned()], false);
    with_correction_dictionary(resources, |dictionary| {
        display = dictionary.mobile_english_display(raw, limit);
    });
    Ok(display)
}

fn validate_mobile_english(raw: &str) -> Result<()> {
    if raw.is_empty()
        || raw.len() > 128
        || !raw
            .bytes()
            .all(|byte| byte.is_ascii_alphabetic() || byte == b'\'')
    {
        return Err(EngineError::invalid(
            diagnostics::INVALID_ENGLISH_COMPLETION_PREFIX,
        ));
    }
    Ok(())
}

/// 复用本线程最近一次打开的词库及其纠错缓存;资源目录变化或词库此前不可用时重新打开。
fn with_correction_dictionary(resources: &str, use_dictionary: impl FnOnce(&EnglishDictionary)) {
    ENGLISH_CORRECTION.with(|slot| {
        let mut slot = slot.borrow_mut();
        let path = Path::new(resources).join(assets::ENGLISH_DICTIONARY);
        if slot
            .as_ref()
            .is_none_or(|(cached, dictionary)| cached != &path || !dictionary.ready())
        {
            *slot = Some((path.clone(), EnglishDictionary::open(&path, None, None)));
        }
        if let Some((_, dictionary)) = slot.as_ref() {
            use_dictionary(dictionary);
        }
    });
}

/// Parallel to `candidates`; empty for ineligible rows (emoji, kaomoji, neither Latin nor Han).
pub fn candidate_glosses(resources: &str, candidates: &[(String, u8)]) -> Result<Vec<String>> {
    candidate_glosses_with_user(resources, "", candidates)
}

/// Hand-written translations outrank learned glosses, which outrank packaged ones; at most two senses shown. Empty `resources` means the learned store only.
pub fn candidate_glosses_with_user(
    resources: &str,
    user_data: &str,
    candidates: &[(String, u8)],
) -> Result<Vec<String>> {
    PACKAGED_GLOSS_DICTIONARY.with_borrow_mut(|packaged_slot| {
        LEARNED_GLOSS_DICTIONARY.with_borrow_mut(|learned_slot| {
            CUSTOM_TRANSLATIONS.with_borrow_mut(|custom_slot| {
                // An empty resource path requests only the user overlay, never a relative `msime-english.db` (bridge.cpp:1161-1162).
                let packaged = if resources.is_empty() {
                    None
                } else {
                    cached_gloss_dictionary(
                        packaged_slot,
                        &Path::new(resources).join(assets::ENGLISH_DICTIONARY),
                    )
                };
                // The user's own `custom_translations.txt` is read directly. The bridge reached it only as the learned store's sidecar (bridge.cpp:1147-1166), so before the first online gloss created `translation-glosses.db` the file Settings writes was ignored.
                let no_custom = CustomTranslations::default();
                let custom = if user_data.is_empty() {
                    &no_custom
                } else {
                    cached_custom_translations(
                        custom_slot,
                        &Path::new(user_data).join(assets::TRANSLATIONS),
                    )
                };
                let learned = if user_data.is_empty() {
                    None
                } else {
                    cached_gloss_dictionary(
                        learned_slot,
                        &Path::new(user_data).join(assets::LEARNED_GLOSSES),
                    )
                };
                let glosses = glosses_from(packaged, custom, learned, candidates);
                if !KEEP_GLOSS_DATABASES_OPEN {
                    *packaged_slot = None;
                    *learned_slot = None;
                }
                glosses
            })
        })
    })
}

/// 手写译文、学到的释义、发布词库三层按顺序查。
fn glosses_from(
    packaged: Option<&EnglishDictionary>,
    custom: &CustomTranslations,
    learned: Option<&EnglishDictionary>,
    candidates: &[(String, u8)],
) -> Result<Vec<String>> {
    if packaged.is_none() && learned.is_none() && custom.en_zh.is_empty() && custom.zh_en.is_empty()
    {
        return Err(EngineError::failed(
            diagnostics::CANDIDATE_GLOSS_UNAVAILABLE,
        ));
    }
    let lookup = |dictionary: &EnglishDictionary, key: &str, chinese_to_english: bool| {
        candidate_gloss_display(&if chinese_to_english {
            dictionary.query_english_gloss(key)
        } else {
            dictionary.query_chinese_gloss(key)
        })
    };
    Ok(candidates
        .iter()
        .map(|(text, source)| {
            let Some((key, chinese_to_english)) = candidate_gloss_key(text, *source) else {
                return String::new();
            };
            let hand_written = if chinese_to_english {
                custom.zh_en.get(&key)
            } else {
                custom.en_zh.get(&key)
            };
            if let Some(gloss) = hand_written {
                return candidate_gloss_display(gloss);
            }
            let gloss = learned
                .map(|dictionary| lookup(dictionary, &key, chinese_to_english))
                .unwrap_or_default();
            if !gloss.is_empty() {
                return gloss;
            }
            packaged
                .map(|dictionary| lookup(dictionary, &key, chinese_to_english))
                .unwrap_or_default()
        })
        .collect())
}

pub fn save_candidate_gloss(
    user_data: &str,
    chinese_to_english: bool,
    key: &str,
    gloss: &str,
) -> bool {
    upsert_gloss(
        &Path::new(user_data).join(assets::LEARNED_GLOSSES),
        chinese_to_english,
        key,
        gloss,
    )
}

/// Chinese rows only, from an offline `zh-<lang>.db` at `user_version = 1` whose `meta.target_language` matches; never creates the file.
pub fn candidate_target_glosses(
    database_path: &str,
    target_language: &str,
    candidates: &[(String, u8)],
) -> Result<Vec<String>> {
    // An empty path would open a private temporary database rather than fail.
    if database_path.is_empty() {
        return Err(EngineError::failed(diagnostics::OFFLINE_GLOSS_UNAVAILABLE));
    }
    let path = Path::new(database_path);
    TARGET_GLOSS_CONNECTIONS.with_borrow_mut(|connections| {
        // 版本和语言每次照旧检查,缓存只省掉打开文件、读结构和准备语句。不是普通文件(不存在、符号链接)时不进缓存,照原路打开,得到原来的错误。
        let identity = FileIdentity::at(path);
        let cached = identity.and_then(|identity| {
            connections.iter().position(|(cached, cached_identity, _)| {
                cached == path && *cached_identity == identity
            })
        });
        let index = match cached {
            Some(index) => index,
            None => {
                connections.retain(|(cached, _, _)| cached != path);
                let connection = open_target_gloss_database(path)?;
                let Some(identity) = identity.filter(|_| KEEP_GLOSS_DATABASES_OPEN) else {
                    return target_glosses_from(&connection, target_language, candidates);
                };
                if connections.len() >= TARGET_GLOSS_CONNECTION_CAPACITY {
                    connections.remove(0);
                }
                connections.push((path.to_path_buf(), identity, connection));
                connections.len() - 1
            }
        };
        target_glosses_from(&connections[index].2, target_language, candidates)
    })
}

fn open_target_gloss_database(path: &Path) -> Result<Connection> {
    let database_path = crate::paths::sqlite_read_only_path_no_follow(path)
        .map_err(|_| EngineError::failed(diagnostics::OFFLINE_GLOSS_UNAVAILABLE))?;
    let connection = Connection::open_with_flags(
        &database_path,
        OpenFlags::SQLITE_OPEN_READ_ONLY
            | OpenFlags::SQLITE_OPEN_NOFOLLOW
            | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
    )
    .map_err(|_| EngineError::failed(diagnostics::OFFLINE_GLOSS_UNAVAILABLE))?;
    // bridge.cpp:1194 set no busy timeout; rusqlite's default 5 s wait is not the reference's behaviour.
    connection
        .busy_timeout(std::time::Duration::ZERO)
        .map_err(|_| EngineError::failed(diagnostics::OFFLINE_GLOSS_UNAVAILABLE))?;
    Ok(connection)
}

fn target_glosses_from(
    connection: &Connection,
    target_language: &str,
    candidates: &[(String, u8)],
) -> Result<Vec<String>> {
    let unreadable = |_| EngineError::failed(diagnostics::OFFLINE_GLOSS_UNREADABLE);
    // SQLite opens lazily, so a file that is not a database first fails here, as it did in the C++ prepare (bridge.cpp:1198-1206).
    let version = connection
        .prepare_cached("PRAGMA user_version")
        .map_err(unreadable)?
        .query_row((), |row| row.get::<_, i64>(0))
        // A step that fails reads as a version the host cannot use, as the C++ check did (bridge.cpp:1207-1208).
        .ok();
    if version != Some(1) {
        return Err(EngineError::failed(
            diagnostics::OFFLINE_GLOSS_VERSION_UNSUPPORTED,
        ));
    }
    let stored = connection
        .prepare_cached("SELECT value FROM meta WHERE key = 'target_language'")
        .map_err(unreadable)?
        .query_row((), |row| row.get::<_, Option<String>>(0))
        .ok()
        .flatten();
    if stored.as_deref() != Some(target_language) {
        return Err(EngineError::failed(
            diagnostics::OFFLINE_GLOSS_LANGUAGE_MISMATCH,
        ));
    }
    let mut lookup = connection
        .prepare_cached("SELECT gloss FROM zh_glosses WHERE chinese = ?1")
        .map_err(unreadable)?;
    let mut output = Vec::with_capacity(candidates.len());
    for (text, source) in candidates {
        let gloss = match candidate_gloss_key(text, *source) {
            Some((key, true)) => lookup
                .query_row((key,), |row| row.get::<_, Option<String>>(0))
                .optional()
                .map_err(|_| EngineError::failed(diagnostics::OFFLINE_GLOSS_READ_FAILED))?
                .flatten()
                .map(|gloss| candidate_gloss_display(&gloss))
                .unwrap_or_default(),
            _ => String::new(),
        };
        output.push(gloss);
    }
    Ok(output)
}

/// IPA of lowercase English words from a `pronunciations/en-phonetic.db` built by scripts/build_pronunciations.py, parallel to `words`; an unknown word is empty. Like the offline gloss files, the version and kind it states must match, so a gloss database dropped in its place is refused rather than read as IPA; never creates the file.
pub fn english_phonetics(database_path: &str, words: &[String]) -> Result<Vec<String>> {
    // An empty path would open a private temporary database rather than fail.
    if database_path.is_empty() {
        return Err(EngineError::failed(diagnostics::PRONUNCIATION_UNAVAILABLE));
    }
    let database_path = crate::paths::sqlite_read_only_path_no_follow(Path::new(database_path))
        .map_err(|_| EngineError::failed(diagnostics::PRONUNCIATION_UNAVAILABLE))?;
    let connection = Connection::open_with_flags(
        &database_path,
        OpenFlags::SQLITE_OPEN_READ_ONLY
            | OpenFlags::SQLITE_OPEN_NOFOLLOW
            | OpenFlags::SQLITE_OPEN_FULL_MUTEX,
    )
    .map_err(|_| EngineError::failed(diagnostics::PRONUNCIATION_UNAVAILABLE))?;
    connection
        .busy_timeout(std::time::Duration::ZERO)
        .map_err(|_| EngineError::failed(diagnostics::PRONUNCIATION_UNAVAILABLE))?;
    let unreadable = |_| EngineError::failed(diagnostics::PRONUNCIATION_UNREADABLE);
    let version = connection
        .prepare("PRAGMA user_version")
        .map_err(unreadable)?
        .query_row((), |row| row.get::<_, i64>(0))
        .ok();
    if version != Some(1) {
        return Err(EngineError::failed(
            diagnostics::PRONUNCIATION_VERSION_UNSUPPORTED,
        ));
    }
    let kind = connection
        .prepare("SELECT value FROM meta WHERE key = 'kind'")
        .map_err(unreadable)?
        .query_row((), |row| row.get::<_, Option<String>>(0))
        .ok()
        .flatten();
    if kind.as_deref() != Some("en_phonetic") {
        return Err(EngineError::failed(
            diagnostics::PRONUNCIATION_KIND_MISMATCH,
        ));
    }
    let mut lookup = connection
        .prepare("SELECT phonetic FROM en_phonetics WHERE word = ?1")
        .map_err(unreadable)?;
    words
        .iter()
        .map(|word| {
            lookup
                .query_row((word,), |row| row.get::<_, Option<String>>(0))
                .optional()
                .map(|phonetic| phonetic.flatten().unwrap_or_default())
                .map_err(|_| EngineError::failed(diagnostics::PRONUNCIATION_READ_FAILED))
        })
        .collect()
}

/// `open_dictionary` (bridge.cpp:1150-1158): only a regular file whose prefix statement prepares.
fn open_gloss_dictionary(path: &Path) -> Option<EnglishDictionary> {
    if !std::fs::symlink_metadata(path).ok()?.file_type().is_file() {
        return None;
    }
    Some(EnglishDictionary::open(path, None, None)).filter(EnglishDictionary::ready)
}

/// `slot` 里那份词库,词库或它旁边的 `custom_translations.txt` 换过时重新打开;打不开的不留,下一次照旧重试。
fn cached_gloss_dictionary<'a>(
    slot: &'a mut Option<(GlossDictionaryKey, EnglishDictionary)>,
    path: &Path,
) -> Option<&'a EnglishDictionary> {
    let sidecar = path
        .parent()
        .unwrap_or(Path::new(""))
        .join(assets::TRANSLATIONS);
    let key = (
        path.to_path_buf(),
        FileIdentity::at(path),
        FileIdentity::at(&sidecar),
    );
    if slot.as_ref().is_none_or(|(cached, _)| cached != &key) {
        *slot = open_gloss_dictionary(path).map(|dictionary| (key, dictionary));
    }
    slot.as_ref().map(|(_, dictionary)| dictionary)
}

/// `slot` 里读好的自定义译文,文件换过或改过时重新读。
fn cached_custom_translations<'a>(
    slot: &'a mut Option<CachedCustomTranslations>,
    path: &Path,
) -> &'a CustomTranslations {
    let key = (path.to_path_buf(), FileIdentity::at(path));
    if slot.as_ref().is_none_or(|(cached, _)| cached != &key) {
        *slot = Some((key, load_custom_translations(path)));
    }
    &slot
        .as_ref()
        .expect("custom translations were just cached")
        .1
}

/// `candidate_gloss_key` (bridge.cpp:149-188): `(key, chinese_to_english)`, or `None` for a row that gets no gloss. Emoji and kaomoji never do; text of ASCII letters, spaces, `-` and `'` with at least one letter is looked up lowercased as English; otherwise any Han character makes the whole text a Chinese key. `&str` is valid UTF-8, so the C++ rejection of malformed text cannot arise.
///
/// 日文混输行(`CandidateSource::MixedJapanese`)也没有释义:私、寿司 这样的汉字是日文,拿去查中文释义会在日文下面写出中文意思,和日文方案、临时日文不出释义一致。
///
/// 脚本插件的行(`CandidateSource::Plugin`)也没有释义:插件的文字不交给任何释义来源,宿主同样不为它请求在线释义或翻译。
pub(super) fn candidate_gloss_key(text: &str, source: u8) -> Option<(String, bool)> {
    if source == CandidateSource::Emoji as u8
        || source == CandidateSource::Kaomoji as u8
        || source == CandidateSource::MixedJapanese as u8
        || source == CandidateSource::Plugin as u8
    {
        return None;
    }
    let latin = text
        .bytes()
        .all(|byte| byte.is_ascii_alphabetic() || matches!(byte, b' ' | b'-' | b'\''));
    if latin && text.bytes().any(|byte| byte.is_ascii_alphabetic()) {
        return Some((text.to_ascii_lowercase(), false));
    }
    if text.chars().any(is_gloss_han) {
        return Some((text.to_owned(), true));
    }
    None
}

/// The bridge's Han ranges (bridge.cpp:143-148), narrower than `text::is_han`: no U+3007 and nothing past U+2FA1F.
fn is_gloss_han(character: char) -> bool {
    matches!(character as u32, 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x2FA1F)
}

/// `candidate_gloss_display` (bridge.cpp:206-237): senses split on `;` or `;`, ASCII whitespace collapsed, empty senses dropped, at most two joined with `"; "`. A control character anywhere in the result withholds the whole gloss, because a learned gloss came from the network and must not reach the candidate window unfiltered.
pub(super) fn candidate_gloss_display(text: &str) -> String {
    // Joining two senses adds at most one byte beyond the source's separators.
    let mut output = String::with_capacity(text.len() + 1);
    let mut count = 0;
    let mut rest = text;
    while count < MAXIMUM_GLOSS_SENSES {
        let ascii = rest.find(';');
        let fullwidth = rest.find(FULLWIDTH_SENSE_DELIMITER);
        let (end, width) = match (ascii, fullwidth) {
            (_, Some(wide)) if ascii.is_none_or(|narrow| wide < narrow) => {
                (Some(wide), FULLWIDTH_SENSE_DELIMITER.len())
            }
            (narrow, _) => (narrow, 1),
        };
        let sense = collapse_ascii_whitespace(&rest[..end.unwrap_or(rest.len())]);
        if !sense.is_empty() {
            if !output.is_empty() {
                output.push_str("; ");
            }
            output.push_str(&sense);
            count += 1;
        }
        let Some(end) = end else {
            break;
        };
        rest = &rest[end + width..];
    }
    if output
        .chars()
        .any(|character| matches!(character as u32, 0..=0x1F | 0x7F..=0x9F))
    {
        return String::new();
    }
    output
}

/// bridge.cpp:189-205: runs of space, tab, CR and LF become one space; leading and trailing runs vanish.
fn collapse_ascii_whitespace(text: &str) -> String {
    let mut output = String::with_capacity(text.len());
    let mut pending_space = false;
    for character in text.chars() {
        if matches!(character, ' ' | '\t' | '\r' | '\n') {
            pending_space = !output.is_empty();
            continue;
        }
        if pending_space {
            output.push(' ');
            pending_space = false;
        }
        output.push(character);
    }
    output
}