Files
fanlusky c9b86e0026 feat(engine): 支持用户自定义双拼方案
用户在数据目录 shuangpin\custom\ 下放 TOML 文件,在设置页以 custom/<文件名>
选用。零声母规则从 o、a、first_letter、full_pinyin 四种里选,声母和韵母
映射由文件指定,可用 [zero_initials] 覆盖个别零声母音节。

- 引擎:新增 CustomShuangpinLayout 与 BuildCustomShuangpinProfile,校验缺键、
  声母撞键,并对全部音节做往返解码,冲突时给出可读的错误信息;新增运行时
  方案注册表,按需加载。
- Server:扫描并解析自定义方案,配置快照带上方案列表与目录;文件缺失或无效
  时回退到小鹤。
- 设置页:仿照自定义辅助码,新增打开目录和刷新按钮;无效方案列出但禁用,
  悬停时显示错误。
- 安装器:覆盖安装与迁移数据目录时保留 shuangpin 目录。
- 文档:docs/custom-shuangpin.example.toml 提供带详细注释的示例。
2026-09-29 11:33:23 +08:00

439 lines
13 KiB
C++

#include "shuangpin_profile.h"
#include <deque>
#include <mutex>
namespace
{
struct ProfileRegistry
{
std::mutex mutex;
std::deque<ShuangpinProfile> storage;
std::unordered_map<std::string, const ShuangpinProfile *> by_name;
};
ProfileRegistry &Registry()
{
static ProfileRegistry registry;
return registry;
}
const ShuangpinProfile *FindRegisteredProfile(std::string_view name)
{
auto &registry = Registry();
std::lock_guard lock(registry.mutex);
const auto found = registry.by_name.find(std::string(name));
return found == registry.by_name.end() ? nullptr : found->second;
}
} // namespace
const ShuangpinProfile &GetXiaoheShuangpinProfile()
{
static const ShuangpinProfile profile{
"xiaohe",
{
{"sh", "u"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "aa"},
{"ai", "ai"},
{"an", "an"},
{"ao", "ao"},
{"ang", "ah"},
{"e", "ee"},
{"ei", "ei"},
{"en", "en"},
{"eng", "eg"},
{"er", "er"},
{"o", "oo"},
{"ou", "ou"},
},
{
{"iu", "q"}, {"ei", "w"}, {"e", "e"}, {"uan", "r"}, {"ue", "t"}, {"ve", "t"}, {"un", "y"},
{"u", "u"}, {"i", "i"}, {"uo", "o"}, {"o", "o"}, {"ie", "p"}, {"a", "a"}, {"ong", "s"},
{"iong", "s"}, {"ai", "d"}, {"en", "f"}, {"eng", "g"}, {"ang", "h"}, {"an", "j"}, {"uai", "k"},
{"ing", "k"}, {"uang", "l"}, {"iang", "l"}, {"ou", "z"}, {"ua", "x"}, {"ia", "x"}, {"ao", "c"},
{"ui", "v"}, {"v", "v"}, {"in", "b"}, {"iao", "n"}, {"ian", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetZiranmaShuangpinProfile()
{
static const ShuangpinProfile profile{
"ziranma",
{
{"sh", "u"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "aa"},
{"ai", "ai"},
{"an", "an"},
{"ao", "ao"},
{"ang", "ah"},
{"e", "ee"},
{"ei", "ei"},
{"en", "en"},
{"eng", "eg"},
{"er", "er"},
{"o", "oo"},
{"ou", "ou"},
},
{
{"iu", "q"}, {"ia", "w"}, {"ua", "w"}, {"e", "e"}, {"uan", "r"}, {"ue", "t"}, {"ve", "t"},
{"ing", "y"}, {"uai", "y"}, {"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"un", "p"},
{"a", "a"}, {"iong", "s"}, {"ong", "s"}, {"iang", "d"}, {"uang", "d"}, {"en", "f"}, {"eng", "g"},
{"ang", "h"}, {"an", "j"}, {"ao", "k"}, {"ai", "l"}, {"ei", "z"}, {"ie", "x"}, {"iao", "c"},
{"ui", "v"}, {"v", "v"}, {"ou", "b"}, {"in", "n"}, {"ian", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetShoudaoShuangpinProfile()
{
static const ShuangpinProfile profile{
"shoudao",
{
{"sh", "e"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "aa"},
{"ai", "ai"},
{"an", "an"},
{"ao", "ao"},
{"ang", "ay"},
// sh sits on "e", so "ee"/"ei"/"ef" would collide with she/shi/sheng.
{"e", "ue"},
{"ei", "ui"},
{"en", "en"},
{"eng", "uf"},
{"er", "er"},
{"o", "oo"},
{"ou", "ou"},
},
{
{"iu", "q"}, {"ua", "w"}, {"e", "e"}, {"ie", "r"}, {"uan", "t"}, {"ang", "y"}, {"u", "u"},
{"i", "i"}, {"o", "o"}, {"uo", "o"}, {"iao", "p"}, {"a", "a"}, {"ou", "s"}, {"ao", "d"},
{"eng", "f"}, {"uai", "g"}, {"ing", "g"}, {"ong", "h"}, {"iong", "h"}, {"an", "j"}, {"en", "k"},
{"ia", "k"}, {"ai", "l"}, {"ue", "l"}, {"un", "z"}, {"iang", "x"}, {"uang", "x"}, {"in", "c"},
{"v", "v"}, {"ui", "v"}, {"ve", "b"}, {"ian", "n"}, {"ei", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetMicrosoftShuangpinProfile()
{
static const ShuangpinProfile profile{
"microsoft",
{
{"sh", "u"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "oa"},
{"ai", "ol"},
{"an", "oj"},
{"ang", "oh"},
{"ao", "ok"},
{"e", "oe"},
{"ei", "oz"},
{"en", "of"},
{"eng", "og"},
{"er", "or"},
{"o", "oo"},
{"ou", "ob"},
},
{
{"iu", "q"}, {"ia", "w"}, {"ua", "w"}, {"e", "e"}, {"uan", "r"}, {"ue", "t"}, {"ve", "v"},
{"uai", "y"}, {"v", "y"}, {"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"un", "p"},
{"a", "a"}, {"iong", "s"}, {"ong", "s"}, {"iang", "d"}, {"uang", "d"}, {"en", "f"}, {"eng", "g"},
{"ang", "h"}, {"an", "j"}, {"ao", "k"}, {"ai", "l"}, {"ing", ";"}, {"ei", "z"}, {"ie", "x"},
{"iao", "c"}, {"ui", "v"}, {"ou", "b"}, {"in", "n"}, {"ian", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetSogouShuangpinProfile()
{
static const ShuangpinProfile profile{
"sogou",
{
{"sh", "u"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "oa"},
{"ai", "ol"},
{"an", "oj"},
{"ang", "oh"},
{"ao", "ok"},
{"e", "oe"},
{"ei", "oz"},
{"en", "of"},
{"eng", "og"},
{"er", "or"},
{"o", "oo"},
{"ou", "ob"},
},
{
{"iu", "q"}, {"ia", "w"}, {"ua", "w"}, {"e", "e"}, {"uan", "r"}, {"ue", "t"}, {"ve", "t"},
{"uai", "y"}, {"v", "y"}, {"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"un", "p"},
{"a", "a"}, {"iong", "s"}, {"ong", "s"}, {"iang", "d"}, {"uang", "d"}, {"en", "f"}, {"eng", "g"},
{"ang", "h"}, {"an", "j"}, {"ao", "k"}, {"ai", "l"}, {"ing", ";"}, {"ei", "z"}, {"ie", "x"},
{"iao", "c"}, {"ui", "v"}, {"ou", "b"}, {"in", "n"}, {"ian", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetZiguangShuangpinProfile()
{
static const ShuangpinProfile profile{
"ziguang",
{
{"sh", "i"},
{"ch", "a"},
{"zh", "u"},
},
{
{"a", "oa"},
{"ai", "op"},
{"an", "or"},
{"ang", "os"},
{"ao", "oq"},
{"e", "oe"},
{"ei", "ok"},
{"en", "ow"},
{"eng", "ot"},
{"er", "oj"},
{"o", "oo"},
{"ou", "oz"},
},
{
{"ao", "q"}, {"en", "w"}, {"e", "e"}, {"an", "r"}, {"eng", "t"}, {"in", "y"}, {"uai", "y"},
{"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"ai", "p"}, {"a", "a"}, {"ang", "s"},
{"ie", "d"}, {"ian", "f"}, {"iang", "g"}, {"uang", "g"}, {"ong", "h"}, {"iong", "h"}, {"iu", "j"},
{"ei", "k"}, {"uan", "l"}, {"ing", ";"}, {"ou", "z"}, {"ia", "x"}, {"ua", "x"}, {"v", "v"},
{"iao", "b"}, {"ui", "n"}, {"ue", "n"}, {"ve", "n"}, {"un", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetZhinengAbcShuangpinProfile()
{
static const ShuangpinProfile profile{
"zhinengabc",
{
{"sh", "v"},
{"ch", "e"},
{"zh", "a"},
},
{
{"a", "oa"},
{"ai", "ol"},
{"an", "oj"},
{"ang", "oh"},
{"ao", "ok"},
{"e", "oe"},
{"ei", "oq"},
{"en", "of"},
{"eng", "og"},
{"er", "or"},
{"o", "oo"},
{"ou", "ob"},
},
{
{"ei", "q"},
{"ian", "w"},
{"e", "e"},
{"iu", "r"},
{"iang", "t"},
{"uang", "t"},
{"ing", "y"},
{"u", "u"},
{"i", "i"},
{"o", "o"},
{"uo", "o"},
{"uan", "p"},
{"a", "a"},
{"ong", "s"},
{"iong", "s"},
{"ia", "d"},
{"ua", "d"},
{"en", "f"},
{"eng", "g"},
{"ang", "h"},
{"an", "j"},
{"ao", "k"},
{"ai", "l"},
{"iao", "z"},
{"ie", "x"},
{"in", "c"},
{"uai", "c"},
{"v", "v"},
{"ou", "b"},
{"un", "n"},
{"ue", "m"},
{"ui", "m"},
// The chart also prints "ve" on V, but then lv/nv would decode as both lü and lüe.
// Standard ABC types lüe/nüe on M together with ue.
{"ve", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetGuobiaoShuangpinProfile()
{
static const ShuangpinProfile profile{
"guobiao",
{
{"sh", "u"},
{"ch", "i"},
{"zh", "v"},
},
{
{"a", "aa"},
{"ai", "ak"},
{"an", "af"},
{"ang", "ag"},
{"ao", "ac"},
{"e", "ae"},
{"ei", "ab"},
{"en", "ar"},
{"eng", "ah"},
{"er", "al"},
{"o", "ao"},
{"ou", "ap"},
},
{
// "van"/"vn" on W/Z are the same keys as uan/un after j/q/x/y, so they need no entry.
{"ia", "q"}, {"ua", "q"}, {"uan", "w"}, {"e", "e"}, {"en", "r"}, {"ie", "t"}, {"iu", "y"},
{"uai", "y"}, {"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"ou", "p"}, {"a", "a"},
{"ong", "s"}, {"iong", "s"}, {"ian", "d"}, {"an", "f"}, {"ang", "g"}, {"eng", "h"}, {"ing", "j"},
{"ai", "k"}, {"in", "l"}, {"un", "z"}, {"ue", "x"}, {"ve", "x"}, {"ao", "c"}, {"v", "v"},
{"ui", "v"}, {"ei", "b"}, {"iang", "n"}, {"uang", "n"}, {"iao", "m"},
},
};
return profile;
}
const ShuangpinProfile &GetPinyinJiajiaShuangpinProfile()
{
static const ShuangpinProfile profile{
"pinyinjiajia",
{
{"sh", "i"},
{"ch", "u"},
{"zh", "v"},
},
{
{"a", "aa"},
{"ai", "as"},
{"an", "af"},
{"ang", "ag"},
{"ao", "ad"},
{"e", "ee"},
{"ei", "ew"},
{"en", "er"},
{"eng", "et"},
{"er", "eq"},
{"o", "oo"},
{"ou", "op"},
},
{
{"ing", "q"}, {"ei", "w"}, {"e", "e"}, {"en", "r"}, {"eng", "t"}, {"ong", "y"}, {"iong", "y"},
{"u", "u"}, {"i", "i"}, {"o", "o"}, {"uo", "o"}, {"ou", "p"}, {"a", "a"}, {"ai", "s"},
{"ao", "d"}, {"an", "f"}, {"ang", "g"}, {"iang", "h"}, {"uang", "h"}, {"ian", "j"}, {"iao", "k"},
{"in", "l"}, {"un", "z"}, {"ue", "x"}, {"ve", "x"}, {"uai", "x"}, {"uan", "c"}, {"ui", "v"},
{"v", "v"}, {"ia", "b"}, {"ua", "b"}, {"iu", "n"}, {"ie", "m"},
},
};
return profile;
}
bool ShuangpinProfileUsesSemicolonFinal(const ShuangpinProfile &profile)
{
for (const auto &pair : profile.finals)
{
if (pair.second == ";")
{
return true;
}
}
return false;
}
const ShuangpinProfile &GetShuangpinProfile(std::string_view name)
{
if (name == "sogou")
{
return GetSogouShuangpinProfile();
}
if (name == "ziguang")
{
return GetZiguangShuangpinProfile();
}
if (name == "zhinengabc")
{
return GetZhinengAbcShuangpinProfile();
}
if (name == "guobiao")
{
return GetGuobiaoShuangpinProfile();
}
if (name == "pinyinjiajia")
{
return GetPinyinJiajiaShuangpinProfile();
}
if (name == "ziranma")
{
return GetZiranmaShuangpinProfile();
}
if (name == "shoudao")
{
return GetShoudaoShuangpinProfile();
}
if (name == "microsoft")
{
return GetMicrosoftShuangpinProfile();
}
if (const ShuangpinProfile *registered = FindRegisteredProfile(name))
{
return *registered;
}
return GetXiaoheShuangpinProfile();
}
const ShuangpinProfile &RegisterShuangpinProfile(ShuangpinProfile profile)
{
auto &registry = Registry();
std::lock_guard lock(registry.mutex);
// Config reloads re-register the same file; only an edited layout needs new storage.
if (const auto found = registry.by_name.find(profile.name); found != registry.by_name.end())
{
const ShuangpinProfile &current = *found->second;
if (current.initials == profile.initials && current.zero_initials == profile.zero_initials &&
current.finals == profile.finals)
{
return current;
}
}
// Sessions hold profiles by reference, so a replaced version is kept rather than freed.
const ShuangpinProfile &stored = registry.storage.emplace_back(std::move(profile));
registry.by_name[stored.name] = &stored;
return stored;
}