唠唠五笔解算器实现过程中的一些问题。
我开始学计算机的那会,智能 ABC 已经是主流,所以我从来没有学过五笔,就连双拼都没有学过。打字就纯粹靠手速和对固定词组的位置记忆。所以在第 10 篇里面我才会考虑花大把时间来实现拼音输入效果。
当然拼音输入的预编辑渲染比五笔做起来要麻烦许多,所以还是打算先挑战五笔打字效果,并在五笔打字效果的基础上改进出拼音打字。
字符集
五笔,尤其是 86 版五笔,支持的字符数量是不到 7000 个。具体而言,它支持 GB2312-80 中给定的所有一级和二级字符。如果只是输入常用字的话肯定是够用的。但是遇到稍微稍微不那么常用的字(比如人名重灾区)的时候,这玩意就歇菜了。
虽然从实际用例上来讲,我应该不会用这玩意去搞一些需要用到一二级汉字以外的东西,但是这是为数不多的不需要牺牲「实现的简单」的「正确」——反正无论如何都要做码表解析,对更多汉字的支持无非就是码表的条目数量不同而已。
社区分裂
默认字符集太窄,就有各路有识之士尝试对 86 版五笔进行改进,比如扩展到 Ext-G 的超大码表。但是吧,大家对于字形拆分的理解,以及对于王码本身的理解,都不是特别一样。这就导致了大家各自搞出了相互不兼容的码表——除了国标里面的不到七千个字是定死的以外,其他的字形应该怎么拆部件、应该怎么编码,是优先照顾单字的重码率还是照顾更常用的词组,全都看编码者的个人喜好。
更不用提五笔本身也是有多个版本的了。98 版和新世纪版这俩可以说是完全不兼容的实现,用的人就更少了。
如果说拼音打字的最大问题就是重码、词库和词频差异,那至少它的行为是基本一致的。当我需要用别人的电脑打字的时候,最多就是打得没那么顺手。五笔的话,就跟别人的 Emacs 一样,真的是除了基本编辑以外无从下手啊(不是)。
还是直接用 Ext-G 码表凑合凑合吧,毕竟这玩意又不是我真的要学,只要效果能做得出来就行。
码表解析
五笔这玩意最特殊的点可能还是我需要真的实现半个完整的输入法进来:我需要能查某个字对应的码是什么,然后我还需要能查出来在打出完整的码之前,它的前缀对应的字是什么。用 GitHub Emoji 的朴素查找表肯定是不够用的,毕竟就算只是 7000 个汉字,每次查找都得捋一整个列表也太慢了;我们至少需要整一个字典树。从汉字反查五笔倒是没有特别优雅的实现,直接 std::unordered_map 让哈希表搞定。
加载 Ext-G 超大码表本身并不复杂,将 wubi86_super.dict.yaml 的前 40 行丢掉,然后剩下的内容按 TSV 解析就可以了。我们只关注前两列,至于权重列目前可以暂时不考虑。实际实现的时候,我是选择查找到 ... 行,然后将之后的行按照 TSV 格式解析。
需要注意到的是:重码是肯定会有的,所以字典树的数据节点必须要考虑到这个问题。以及一个暗坑:这个码表几乎全部的序列码都是小写,就偏偏「㹖」的序列是大写的 TRUE. 在加载码表的时候仍然需要手动过一遍小写化才行。
键入序列
有了这个半吊子输入法,实现键入序列就很简单了。
为了方便,键入序列定义为由已经上屏的文字和在预编辑区的文字组成。注意到:已上屏的文字是包括之前的文字的,并不仅仅是当前刚刚打好的字;以及预编辑区可以是空的字符串,表示此时没有预编辑内容:
class KeystrokeSequence {
public:
explicit KeystrokeSequence(const std::string_view view) : _text(view) { }
explicit KeystrokeSequence(const std::string_view view, std::string preedit) : _text(view), _preedit(std::move(preedit)) { }
// getter 略
private:
// 成员略
};
定义一个解算器类:
class WubiSolver {
public:
void solve(std::string_view view) {
/* [0] */
}
// getter 略
private:
std::vector<pf::KeystrokeSequence> _sequences;
};
首先,取到每一个单字:
// in [0]
auto iter = view.cbegin();
while (iter != view.cend()) {
const std::string_view str{
iter, static_cast<size_t>(std::distance(iter, view.cend()))};
const auto pair = ParseUtf8Codepoint(str);
if (pair.first == 0) {
iter += pair.second;
continue;
}
/* [1] */
iter += pair.second;
}
然后反查输入序列:
// in [1]
std::string sequence;
const auto hasComposeSequence = dict.query_sequence(pair.first, sequence));
没有输入序列则直接上屏:
// in [1]
if (!hasComposeSequence) {
// no suitable composing form
// just submit the character itself
spdlog::warn("failed to query sequence for character {}",
pair.first);
std::string_view submittedText{
view.data(),
static_cast<std::string_view::size_type>(
std::distance(view.cbegin(), iter + pair.second))};
_sequences.emplace_back(submittedText);
iter += pair.second;
continue;
}
有输入序列,则需要先判断是不是单码字,如果是的话需要特殊处理一下:
// in [1]
if (sequence.size() == 1) {
const std::string_view previousText{
view.data(),
static_cast<size_t>(std::distance(view.cbegin(), iter))};
const std::string_view submittedText{
view.data(),
static_cast<size_t>(
std::distance(view.cbegin(), iter + pair.second))};
_sequences.emplace_back(previousText,
EncodeUtf8String(pair.first));
_sequences.emplace_back(submittedText);
iter += pair.second;
continue;
}
多次击键则一点点输入并把首要候选字放入预编辑区:
// in [1]
for (size_t i = 1; i < sequence.size(); ++i) {
const std::string_view previousText{
view.data(),
static_cast<size_t>(std::distance(view.cbegin(), iter))};
const std::string_view v{sequence.data(), i};
auto candidate = dict.query_first_candidate(v);
/* [2] */
}
如果提前遇到了需要的字,那么我们可以不输入整个序列直接上屏:
// in [2]
if (candidate == pair.first) {
// we've got the key we need
// submit to pre-edit first
_sequences.emplace_back(previousText,
EncodeUtf8String(candidate));
// then enclose the entire text
const std::string_view submittedText{
view.data(),
static_cast<size_t>(
std::distance(view.cbegin(), iter + pair.second))};
_sequences.emplace_back(submittedText);
break;
}
_sequences.emplace_back(previousText,
EncodeUtf8String(candidate));
以及,如果序列打完了还是没遇到我们需要的字,说明我们的字恰好是在重码里面,那么,也需要立刻上屏:
// in [2]
if (i + 1 >= sequence.size()) {
_sequences.emplace_back(previousText,
EncodeUtf8String(pair.first));
const std::string_view submittedText{
view.data(),
static_cast<size_t>(
std::distance(view.cbegin(), iter + pair.second))};
_sequences.emplace_back(submittedText);
}
简单地做一下小测试:
pf::WubiSolver solver;
solver.solve("滚滚长江东逝水");
const auto& sequences = solver.sequences();
for (const auto& keystroke : sequences) {
spdlog::info("{}[{}]", keystroke.text(), keystroke.preedit());
}
方括号 [] 中就是预输入部分,前面则是已上屏部分;运行结果如下:
7: [2026-06-07 22:23:51.367] [info] [不]
7: [2026-06-07 22:23:51.367] [info] [洋]
7: [2026-06-07 22:23:51.367] [info] [滚]
7: [2026-06-07 22:23:51.367] [info] 滚[]
7: [2026-06-07 22:23:51.367] [info] 滚[不]
7: [2026-06-07 22:23:51.367] [info] 滚[洋]
7: [2026-06-07 22:23:51.367] [info] 滚[滚]
7: [2026-06-07 22:23:51.367] [info] 滚滚[]
7: [2026-06-07 22:23:51.367] [info] 滚滚[和]
7: [2026-06-07 22:23:51.367] [info] 滚滚[长]
7: [2026-06-07 22:23:51.367] [info] 滚滚长[]
7: [2026-06-07 22:23:51.367] [info] 滚滚长[不]
7: [2026-06-07 22:23:51.367] [info] 滚滚长[江]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江[]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江[工]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江[东]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东[]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东[的]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东[折]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东[逝]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东逝[]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东逝[不]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东逝[水]
7: [2026-06-07 22:23:51.367] [info] 滚滚长江东逝水[]
换一个带单码字的 "我能吞下玻璃而不伤身体":
7: [2026-06-07 22:50:29.972] [info] [我]
7: [2026-06-07 22:50:29.972] [info] 我[]
7: [2026-06-07 22:50:29.972] [info] 我[以]
7: [2026-06-07 22:50:29.972] [info] 我[能]
7: [2026-06-07 22:50:29.972] [info] 我能[]
7: [2026-06-07 22:50:29.972] [info] 我能[一]
7: [2026-06-07 22:50:29.972] [info] 我能[天]
7: [2026-06-07 22:50:29.972] [info] 我能[吞]
7: [2026-06-07 22:50:29.972] [info] 我能吞[]
7: [2026-06-07 22:50:29.972] [info] 我能吞[一]
7: [2026-06-07 22:50:29.972] [info] 我能吞[下]
7: [2026-06-07 22:50:29.972] [info] 我能吞下[]
7: [2026-06-07 22:50:29.972] [info] 我能吞下[一]
7: [2026-06-07 22:50:29.972] [info] 我能吞下[下]
7: [2026-06-07 22:50:29.972] [info] 我能吞下[玻]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻[]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻[一]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻[玉]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻[璃]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃[]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃[在]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃[面]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃[而]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃而[]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃而[不]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃而不[]
7: [2026-06-07 22:50:29.972] [info] 我能吞下玻璃而不[人]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不[作]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不[伤]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤[]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤[和]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤[向]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤[身]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤身[]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤身[人]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤身[休]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤身[体]
7: [2026-06-07 22:50:29.973] [info] 我能吞下玻璃而不伤身体[]
剩下的就是在屏幕上把这个东西渲染出来了。
正在加载评论……