Files
msime-windows/engine/shuangpin/shuangpin_custom_profile.cpp
HowGeGeandfanlusky 1671c3a467 fix(engine): recognize complete umlaut aliases in quanpin (#994)
* fix(engine): recognize complete umlaut aliases in quanpin

* refactor(engine): 移除九键输入

九键会话只有引擎公共 Session 门面在用,Server 与 TSF 都没有接入。删掉
NineKeySession、Session 的九键入口与 SessionSnapshot::nine_key_spellings,
以及对应测试和注释。

* fix(engine,server): ü 别名共用一条归一规则,入库与纠错不再产出查不到的键

- 新增 quanpin::is_jqxy_v_alias / canonical_umlaut_spelling,替换 canonical_syllable
  与 to_google_spelling 里各自手抄的别名表,normalize_umlaut_aliases 也改用它。
- 纠错表生成器与运行时生成式空间不再从 jqxy+v 别名派生纠错键(nvan -> jvan 之类),
  手误只纠到 u 的标准写法;重新生成 autocorrect_table.h。
- 个人词库新写入的拼音词、设置页导入的全拼按词库写法入库(xvan -> xuan、lue -> lve),
  否则查询归一后永远查不到;被替换的旧词保留原键,旧行仍可删除。
- 双拼音节集统一排除 jqxy+v 别名,round-trip 检查不再特判。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
2026-10-16 20:48:33 +08:00

253 lines
9.9 KiB
C++

// Builds a ShuangpinProfile from a user-described layout and proves it unambiguous by
// round-tripping every syllable through the same decoder the input session uses.
#include "shuangpin_profile.h"
#include "shuangpin_utils.h"
#include <algorithm>
#include <cctype>
#include <string_view>
#include <unordered_set>
namespace
{
// Finals every layout has to place. "ue" and "ve" stand in for each other when only one is given.
constexpr std::string_view kFinals[] = {"a", "o", "e", "i", "u", "v", "ai", "ei", "ui", "ao", "ou",
"iu", "ie", "ue", "ve", "an", "en", "in", "un", "ang", "eng", "ing",
"ong", "ia", "iao", "ian", "iang", "iong", "ua", "uo", "uai", "uan", "uang"};
constexpr std::string_view kZeroInitialFinals[] = {"a", "ai", "an", "ang", "ao", "e",
"ei", "en", "eng", "er", "o", "ou"};
constexpr std::string_view kInitials[] = {"b", "p", "m", "f", "d", "t", "n", "l", "g", "k", "h", "j",
"q", "x", "r", "z", "c", "s", "y", "w", "zh", "ch", "sh"};
constexpr std::string_view kRetroflexInitials[] = {"zh", "ch", "sh"};
constexpr size_t kMaxReportedConflicts = 8;
template <size_t N> bool contains(const std::string_view (&names)[N], std::string_view name)
{
return std::find(std::begin(names), std::end(names), name) != std::end(names);
}
std::string lowercase(std::string text)
{
std::transform(text.begin(), text.end(), text.begin(),
[](unsigned char ch) { return static_cast<char>(std::tolower(ch)); });
return text;
}
bool is_letter(char ch)
{
return ch >= 'a' && ch <= 'z';
}
bool is_final_key(const std::string &key)
{
return key.size() == 1 && (is_letter(key[0]) || key[0] == ';');
}
std::string zero_initial_code(ShuangpinZeroInitialRule rule, const std::string &final, const std::string &key)
{
switch (rule)
{
case ShuangpinZeroInitialRule::LetterO:
return "o" + key;
case ShuangpinZeroInitialRule::LetterA:
return "a" + key;
case ShuangpinZeroInitialRule::FullPinyin:
if (final.size() == 2)
return final;
[[fallthrough]];
case ShuangpinZeroInitialRule::FirstLetter:
break;
}
return final.substr(0, 1) + key;
}
// Splits a syllable the way a layout types it; empty initial means a zero-initial syllable.
std::pair<std::string, std::string> split_syllable(const std::string &syllable)
{
if (syllable.size() > 2 && syllable[1] == 'h' && std::string_view("zcs").find(syllable[0]) != std::string::npos)
return {syllable.substr(0, 2), syllable.substr(2)};
if (std::string_view("aeo").find(syllable[0]) != std::string::npos)
return {{}, syllable};
return {syllable.substr(0, 1), syllable.substr(1)};
}
void check_round_trip(const ShuangpinProfile &profile, std::vector<std::string> &errors)
{
std::vector<std::string> sorted(ShuangpinUtil::decodable_syllables().begin(),
ShuangpinUtil::decodable_syllables().end());
std::sort(sorted.begin(), sorted.end());
size_t conflicts = 0;
for (const auto &syllable : sorted)
{
std::string code;
const auto [initial, final] = split_syllable(syllable);
if (initial.empty())
{
const auto zero = profile.zero_initials.find(syllable);
if (zero == profile.zero_initials.end())
continue;
code = zero->second;
}
else
{
const auto final_key = profile.finals.find(final);
if (final.empty() || final_key == profile.finals.end())
continue;
const auto initial_key = profile.initials.find(initial);
code = (initial_key == profile.initials.end() ? initial : initial_key->second) + final_key->second;
}
const std::string decoded = ShuangpinUtil::cvt_single_sp_to_pinyin(code, profile);
if (decoded == syllable)
continue;
if (++conflicts <= kMaxReportedConflicts)
{
errors.push_back(decoded.empty() ? "音节 " + syllable + " 按 " + code + " 输入时无法识别"
: "音节 " + syllable + " 按 " + code + " 输入时会被读成 " + decoded);
}
}
if (conflicts > kMaxReportedConflicts)
errors.push_back("另有 " + std::to_string(conflicts - kMaxReportedConflicts) + " 个音节存在同样的冲突");
}
} // namespace
std::optional<ShuangpinZeroInitialRule> ParseShuangpinZeroInitialRule(std::string_view name)
{
if (name == "o")
return ShuangpinZeroInitialRule::LetterO;
if (name == "a")
return ShuangpinZeroInitialRule::LetterA;
if (name == "first_letter")
return ShuangpinZeroInitialRule::FirstLetter;
if (name == "full_pinyin")
return ShuangpinZeroInitialRule::FullPinyin;
return std::nullopt;
}
std::vector<std::string> BuildCustomShuangpinProfile(const CustomShuangpinLayout &layout, ShuangpinProfile &profile)
{
std::vector<std::string> errors;
ShuangpinProfile result;
result.name = layout.name;
std::unordered_map<std::string, std::string> initial_owner; // key -> initial typed with it
for (const auto &initial : kInitials)
{
if (initial.size() == 1)
initial_owner.emplace(std::string(initial), std::string(initial));
}
for (const auto &[raw_initial, raw_key] : layout.initials)
{
const std::string initial = lowercase(raw_initial);
const std::string key = lowercase(raw_key);
if (!contains(kInitials, initial))
{
errors.push_back("未知的声母「" + raw_initial + "」");
continue;
}
if (key.size() != 1 || !is_letter(key[0]))
{
errors.push_back("声母 " + initial + " 的按键必须是一个英文字母,现在是「" + raw_key + "」");
continue;
}
result.initials[initial] = key;
}
for (const auto &initial : kRetroflexInitials)
{
if (result.initials.count(std::string(initial)) == 0 && errors.empty())
errors.push_back("缺少声母 " + std::string(initial) + " 的按键");
}
if (errors.empty())
{
// A remapped initial takes its key away from whichever initial sat there by default.
for (const auto &[initial, key] : result.initials)
{
if (initial.size() == 1)
initial_owner.erase(initial);
}
for (const auto &[initial, key] : result.initials)
{
const auto [owner, inserted] = initial_owner.emplace(key, initial);
if (!inserted && owner->second != initial)
errors.push_back("声母 " + owner->second + " 和 " + initial + " 都在 " + key + " 键上");
}
}
std::string er_key;
for (const auto &[raw_final, raw_key] : layout.finals)
{
const std::string final = lowercase(raw_final);
const std::string key = lowercase(raw_key);
if (final != "er" && !contains(kFinals, final))
{
errors.push_back("未知的韵母「" + raw_final + "」");
continue;
}
if (!is_final_key(key))
{
errors.push_back("韵母 " + final + " 的按键必须是一个英文字母或分号,现在是「" + raw_key + "」");
continue;
}
if (final == "er")
er_key = key;
else
result.finals[final] = key;
}
if (result.finals.count("ue") == 0 && result.finals.count("ve") > 0)
result.finals["ue"] = result.finals["ve"];
if (result.finals.count("ve") == 0 && result.finals.count("ue") > 0)
result.finals["ve"] = result.finals["ue"];
std::string missing;
for (const auto &final : kFinals)
{
if (result.finals.count(std::string(final)) == 0)
missing += (missing.empty() ? "" : "、") + std::string(final);
}
if (!missing.empty())
errors.push_back("缺少韵母的按键:" + missing);
if (!errors.empty())
return errors;
// Most layouts put "er" on R, which is also where this falls back to when the file is silent.
if (er_key.empty())
er_key = "r";
for (const auto &final_view : kZeroInitialFinals)
{
const std::string final(final_view);
const std::string key = final == "er" ? er_key : result.finals.at(final);
result.zero_initials[final] = zero_initial_code(layout.zero_initial_rule, final, key);
}
for (const auto &[raw_final, raw_code] : layout.zero_initial_overrides)
{
const std::string final = lowercase(raw_final);
const std::string code = lowercase(raw_code);
if (!contains(kZeroInitialFinals, final))
{
errors.push_back("「" + raw_final +
"」不是零声母音节,可以单独指定的只有 a、ai、an、ang、ao、e、ei、en、"
"eng、er、o、ou");
continue;
}
if (code.size() != 2 || !is_letter(code[0]) || !is_final_key(code.substr(1)))
{
errors.push_back("零声母 " + final + " 的编码必须是两个键(第二个可以是分号),现在是「" + raw_code + "」");
continue;
}
result.zero_initials[final] = code;
}
std::unordered_map<std::string, std::string> zero_owner;
for (const auto &final : kZeroInitialFinals)
{
const std::string &code = result.zero_initials.at(std::string(final));
const auto [owner, inserted] = zero_owner.emplace(code, std::string(final));
if (!inserted)
errors.push_back("零声母 " + owner->second + " 和 " + std::string(final) + " 的编码都是 " + code);
}
if (!errors.empty())
return errors;
check_round_trip(result, errors);
if (errors.empty())
profile = std::move(result);
return errors;
}