Files
msime-windows/engine/local_modes/radical_query.cpp
T
Jia Suiandfanlusky d414268b5f [Feat] U 模式重构为部件拆字反查 (#973)
* feat(engine,server,windows,ui-html,installer): U 模式重构为部件拆字反查 (#971)

把 U 模式从 Unicode 十六进制码位输入改成部件拆字反查:Shift+U 后输入各
组成部分的拼音连写(晶 = ririri)即可出字,候选上反显注音。

- 引擎:新增 local_modes/radical_query(前缀匹配、字频降序),数据表
  engine/radical/decomposition.txt 与生成脚本;LocalInputMode::Unicode
  改为 Radical;删除 unicode_query。
- Server:g_radical_mode_triggered + IsRadicalCompositionActive + 候选分发,
  用 cpp-pinyin 反显注音;删除 Unicode 码位路径。
- 前端 TSF:删除 U 模式的十六进制/数字特例与影子预判,按普通组合处理。
- 配置:utility.unicode_mode 改为 utility.radical_mode。
- 部署:assets 登记 radical_decomposition,安装器与 CI 拷表。

数据来源 mirtlecn/rime-radical-pinyin (GPL-3.0),授权见 engine/radical/NOTICE.md。

* feat(server): U 模式反显拼音改为带调风格(jīng)

* fix(server,installer): 修复 U 模式 CI(拆字表打包夹具与 clang-format)

* fix(engine,server): 修正 U 模式按键错位、重复候选与配置迁移

- Server 在 U 模式不再吞掉撇号、分号、斜杠、大写字母等键:TSF 已把它们记进按键缓冲,
  吞掉又不重同步会让两侧串和光标错位。照常插入,含非 a-z 的码查不出候选
- 拆字查询全码命中排在仅前缀命中之前,同一个字的几种拆法只出一次
- 升级合并把旧键 utility.unicode_mode 的值带到 utility.radical_mode,读取时也兜底旧键
- 生成脚本只收汉字,重建拆字表去掉 abc 码下的 51 行数字、字母与符号
- 开着 U 模式时在后台预热拆字表与 cpp-pinyin 词典,避开首个按键的冷加载
- Server 按会话的 RuntimePaths 查拆字表,删掉走全局数据目录的便捷重载
- cpp-pinyin 初始化合并到 utils/hanzi_pinyin,设置页导入注音共用;U 模式与快捷短语的收键分支合并

---------

Co-authored-by: fanlusky <1730976608@qq.com>
2026-10-16 01:01:18 +08:00

194 lines
6.3 KiB
C++

#include "radical_query.h"
#include "../core/data_path.h"
#include <algorithm>
#include <cstdint>
#include <fstream>
#include <memory>
#include <mutex>
#include <string_view>
#include <unordered_map>
#include <unordered_set>
#include <utility>
namespace metasequoia::local_modes
{
namespace
{
// 一行拆字数据。code 是部件拼音连写、word 是汉字、weight 是字频。
struct Entry
{
std::string code;
std::string word;
std::int64_t weight = 0;
};
struct Table
{
// 文件顺序即字频降序(生成脚本已排序),所以某前缀的首个命中就是它最高频的那条。
std::vector<Entry> entries;
// entries 的下标按 code 排序。前缀命中的行在排序视图里连续,查询只扫这一小段。
std::vector<std::size_t> by_code;
};
// 生成脚本不会写 BOM,但保留与 helpcode 相同的防御:表由外部工具产出,首行多一个 BOM
// 不该让整张表失效。
constexpr char kUtf8Bom[] = "\xEF\xBB\xBF";
bool is_lower_ascii_code(const std::string &code)
{
return std::all_of(code.begin(), code.end(),
[](unsigned char character) { return character >= 'a' && character <= 'z'; });
}
std::shared_ptr<const Table> load_table(const std::filesystem::path &path)
{
auto table = std::make_shared<Table>();
std::ifstream input(path, std::ios::binary);
if (!input)
{
return {};
}
std::string line;
bool first_line = true;
while (std::getline(input, line))
{
if (first_line && line.rfind(kUtf8Bom, 0) == 0)
{
line.erase(0, 3);
}
first_line = false;
if (!line.empty() && line.back() == '\r')
{
line.pop_back();
}
if (line.empty() || line.front() == '#')
{
continue;
}
const auto first_tab = line.find('\t');
if (first_tab == std::string::npos)
{
continue;
}
const auto second_tab = line.find('\t', first_tab + 1);
if (second_tab == std::string::npos)
{
continue;
}
std::string code = line.substr(0, first_tab);
std::string word = line.substr(first_tab + 1, second_tab - first_tab - 1);
// 拆字输入只可能是 a-z,码字域之外的码(繁体、注音、含撇号未归一)没有查询语义。
if (code.empty() || word.empty() || !is_lower_ascii_code(code))
{
continue;
}
std::int64_t weight = 0;
try
{
weight = std::stoll(line.substr(second_tab + 1));
}
catch (...)
{
continue;
}
table->entries.push_back({std::move(code), std::move(word), weight});
}
table->by_code.resize(table->entries.size());
for (std::size_t index = 0; index < table->entries.size(); ++index)
{
table->by_code[index] = index;
}
std::sort(table->by_code.begin(), table->by_code.end(), [&](std::size_t left, std::size_t right) {
return table->entries[left].code < table->entries[right].code;
});
return table;
}
// 表加载后不可变,进程内按路径只读一次。按路径缓存而不是单个静态 local,是为了让测试
// 能指向自己的临时表而不与部署布局打架。
std::shared_ptr<const Table> cached_table(const std::filesystem::path &path)
{
static std::mutex mutex;
static std::unordered_map<std::string, std::shared_ptr<const Table>> cache;
const std::string key = path_to_utf8(path);
std::lock_guard<std::mutex> lock(mutex);
const auto found = cache.find(key);
if (found != cache.end())
{
return found->second;
}
std::shared_ptr<const Table> table = load_table(path);
cache.emplace(key, table);
return table;
}
} // namespace
std::vector<WordItem> query_radical(const std::filesystem::path &decomposition_path, const std::string &code, int limit)
{
if (limit <= 0 || code.empty() || !is_lower_ascii_code(code))
{
return {};
}
const std::shared_ptr<const Table> table = cached_table(decomposition_path);
if (!table || table->entries.empty())
{
return {};
}
const auto lower = std::lower_bound(
table->by_code.begin(), table->by_code.end(), code,
[&](std::size_t index, const std::string &value) { return table->entries[index].code < value; });
// 全码命中在前、仅前缀命中在后,各自按字频:打全了某个生僻字的拆法,它不能被一串更高频的
// 长码挤出 limit。
std::vector<std::size_t> exact_matches;
std::vector<std::size_t> prefix_matches;
for (auto it = lower; it != table->by_code.end(); ++it)
{
const std::string &entry_code = table->entries[*it].code;
if (entry_code.size() < code.size() || entry_code.compare(0, code.size(), code) != 0)
{
// 同一前缀的行在排序视图里连续,越过前缀范围即可停。
break;
}
(entry_code.size() == code.size() ? exact_matches : prefix_matches).push_back(*it);
}
// 下标就是文件顺序(字频降序),排序后即高频优先。
std::sort(exact_matches.begin(), exact_matches.end());
std::sort(prefix_matches.begin(), prefix_matches.end());
const auto count = static_cast<std::size_t>(limit);
std::vector<WordItem> result;
// 一个字常有几种拆法(駉 = majiong / majiongkou / matongkou),共享同一前缀时只出一次,
// 位置取它排得最前的那条。
std::unordered_set<std::string_view> seen;
for (const auto *matches : {&exact_matches, &prefix_matches})
{
for (const std::size_t index : *matches)
{
if (result.size() >= count)
{
return result;
}
const Entry &entry = table->entries[index];
if (!seen.insert(entry.word).second)
{
continue;
}
// 注音不在这里填:引擎没有汉字→拼音能力,由 Server 组装候选时补。
result.emplace_back(std::string{}, entry.word, entry.weight, CandidateSource::Generated);
}
}
return result;
}
void preload_radical_table(const std::filesystem::path &decomposition_path)
{
cached_table(decomposition_path);
}
} // namespace metasequoia::local_modes