diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index f19ebc07..fbfc1236 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -40,6 +40,9 @@ set(METASEQUOIA_IME_ENGINE_SOURCES core/input_session_composition.cpp core/input_session_editing.cpp core/input_session_candidates.cpp + direct_helpcode/direct_lattice.cpp + direct_helpcode/direct_resolver.cpp + direct_helpcode/spelling_graph.cpp english/english_dictionary.cpp japanese/japanese_matrix_search.cpp japanese/japanese_sentence_decoder.cpp @@ -253,6 +256,10 @@ if(BUILD_TESTING) target_link_libraries(test_frequency_input_session PRIVATE MetasequoiaIme::Engine) add_test(NAME frequency_input_session COMMAND test_frequency_input_session) + add_executable(test_direct_helpcode tests/src/test_direct_helpcode.cpp) + target_link_libraries(test_direct_helpcode PRIVATE MetasequoiaIme::Engine) + add_test(NAME direct_helpcode COMMAND test_direct_helpcode) + add_executable(test_english_input_session tests/src/test_english_input_session.cpp) target_link_libraries(test_english_input_session PRIVATE MetasequoiaIme::Engine) add_test(NAME english_input_session COMMAND test_english_input_session) diff --git a/engine/contracts/direct_helpcode.h b/engine/contracts/direct_helpcode.h new file mode 100644 index 00000000..390fdf99 --- /dev/null +++ b/engine/contracts/direct_helpcode.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +// 双拼直接辅助码(万象式,规则见 engine/direct_helpcode/spelling_graph.h)的按键形状。 +// +// 直接辅助码不要引导键:辅码字母就是普通字母键,TSF 本来就吃。要额外判断的只有两个键: +// +// / 四码(双拼 + 两位辅码)后面的终止键:uiab/。不当编码键时它是「上屏高亮候选 + /」。 +// ; 微软/搜狗等双拼的 ing 韵母。平时按「所在一节是奇数键」判断,可辅码会打乱奇偶(uia 后面 +// 接 x; 时这一节是偶数键),开着直接辅助码时改成只看前一个键是不是字母。 +// +// TSF 在同步吃键阶段先判断,Server 随后按同一条规则决定收不收、引擎按它解析。三处必须一致,否则 +// 两边的输入串会分叉。TSF 不链接引擎、没有音节表,所以这里只看形状,/ 到底凑不凑得成四码由引擎 +// 判断,凑不成时引擎把它当分隔符处理,不会让整串作废。 +namespace FanyImeDirectHelpcode +{ +inline constexpr char kSlash = '/'; + +template constexpr bool IsAsciiLetter(Char ch) +{ + return (ch >= Char('a') && ch <= Char('z')) || (ch >= Char('A') && ch <= Char('Z')); +} + +// 光标停在 text[caret] 时能否插入 /:光标前是「两键音节 + 两个辅码字母」的形状(音节第二键可以 +// 是 ; 韵母),且光标后不是已经有一个 /。 +template constexpr bool AcceptsSlashAt(const Char *text, std::size_t size, std::size_t caret) +{ + if (caret < 4 || caret > size || (caret < size && text[caret] == Char(kSlash))) + { + return false; + } + return IsAsciiLetter(text[caret - 1]) && IsAsciiLetter(text[caret - 2]) && + (IsAsciiLetter(text[caret - 3]) || text[caret - 3] == Char(';')) && IsAsciiLetter(text[caret - 4]); +} + +// 光标停在 text[caret] 时 ; 能否作为 ing 韵母(只对用 ; 当韵母的双拼方案有意义)。 +template constexpr bool AcceptsSemicolonFinalAt(const Char *text, std::size_t size, std::size_t caret) +{ + return caret >= 1 && caret <= size && IsAsciiLetter(text[caret - 1]); +} +} // namespace FanyImeDirectHelpcode diff --git a/engine/contracts/tests/windows_ipc_contract.cpp b/engine/contracts/tests/windows_ipc_contract.cpp index 7e731d90..883fdcf9 100644 --- a/engine/contracts/tests/windows_ipc_contract.cpp +++ b/engine/contracts/tests/windows_ipc_contract.cpp @@ -1,3 +1,4 @@ +#include "../direct_helpcode.h" #include "../ipc_negotiation.h" #include "../mid_sentence_helpcode.h" #include "../voice_composition_pipe.h" @@ -136,7 +137,17 @@ int main() CHECK(FanyImeWorkerReplyType::CommitCandidateAndContinue == 27); CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeChanged == 28); CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeSemicolonChanged == 29); - CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::MidSentenceHelpcodeSemicolonChanged); + CHECK(FanyImeWorkerReplyType::DirectHelpcodeChanged == 30); + CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::DirectHelpcodeChanged); + // 直接辅助码的 / 与 ; 形状规则,TSF(WCHAR)与引擎(char)共用。 + const auto slash_at = [](const std::wstring &text, std::size_t caret) { + return FanyImeDirectHelpcode::AcceptsSlashAt(text.data(), text.size(), caret); + }; + CHECK(slash_at(L"uiab", 4) && slash_at(L"x;ab", 4) && !slash_at(L"uia", 3) && !slash_at(L"ui'b", 4)); + CHECK(!slash_at(L"uiab/", 4) && slash_at(L"uiabui", 4) && !slash_at(L"uiab", 5)); + CHECK(FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("uiax", 4, 4) && + !FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("uix;", 4, 4) && + !FanyImeDirectHelpcode::AcceptsSemicolonFinalAt("", 0, 0)); // TSF(WCHAR)与引擎(char)共用同一条句中辅助码形状规则。 const auto accepts = [](const std::wstring &text) { return FanyImeMidSentenceHelpcode::AcceptsMarker(text.data(), text.size()); diff --git a/engine/contracts/windows_ipc.h b/engine/contracts/windows_ipc.h index 79f4a5bc..2f743753 100644 --- a/engine/contracts/windows_ipc.h +++ b/engine/contracts/windows_ipc.h @@ -416,7 +416,11 @@ constexpr std::uint32_t MidSentenceHelpcodeChanged = 28; // Same as MidSentenceHelpcodeChanged for the optional ';' trigger. Payload "0"/"1". A separate // opcode keeps 28 meaning "'`' is a trigger" for older DLLs, which ignore this one. constexpr std::uint32_t MidSentenceHelpcodeSemicolonChanged = 29; -constexpr std::uint32_t MaxKnown = MidSentenceHelpcodeSemicolonChanged; +// Whether shuangpin direct helpcode (Wanxiang style, no guide key) is on. Payload "0"/"1". TSF then eats '/' after +// a four-key code (FanyImeDirectHelpcode::AcceptsSlashAt) and stops requiring an odd chunk before the ';' final +// (FanyImeDirectHelpcode::AcceptsSemicolonFinalAt). Same single "0"/"1" payload rule as opcode 28. +constexpr std::uint32_t DirectHelpcodeChanged = 30; +constexpr std::uint32_t MaxKnown = DirectHelpcodeChanged; // Source compatibility for the Server's historical spellings. constexpr std::uint32_t SwitchToEn = SwitchToEnglish; constexpr std::uint32_t SwitchToCn = SwitchToChinese; diff --git a/engine/core/ime_session.cpp b/engine/core/ime_session.cpp index c8579159..1f35a96a 100644 --- a/engine/core/ime_session.cpp +++ b/engine/core/ime_session.cpp @@ -44,6 +44,29 @@ ImeSession::ImeSession(SchemeType scheme_type, const ShuangpinProfile &shuangpin scheme_(create_scheme(scheme_type)) { bind_wubi_scheme(); + bind_shuangpin_scheme(); +} + +void ImeSession::bind_shuangpin_scheme() +{ + if (auto *shuangpin_scheme = dynamic_cast(scheme_.get())) + { + shuangpin_scheme->set_direct_helpcode(enable_direct_helpcode_); + } +} + +void ImeSession::set_direct_helpcode_enabled(bool enabled) +{ + enable_direct_helpcode_ = enabled; + bind_shuangpin_scheme(); +} + +void ImeSession::resolve_direct_helpcode(QueryRequest &request) +{ + if (enable_direct_helpcode_ && request.valid && request.scheme == SchemeType::Shuangpin) + { + provider_registry_.resolve_direct_helpcode(request); + } } void ImeSession::bind_wubi_scheme() @@ -77,6 +100,7 @@ void ImeSession::switch_scheme(SchemeType scheme_type) { scheme_ = create_scheme(scheme_type); bind_wubi_scheme(); + bind_shuangpin_scheme(); state_ = CompositionState{}; } @@ -202,6 +226,7 @@ std::vector ImeSession::query_raw_candidates(const std::string &raw_in QueryRequest request = query_scheme->build_request(); apply_request_options(request); ApplyShuangpinHelpcodeSegmentation(request, shuangpin_profile_); + resolve_direct_helpcode(request); if (!request.valid) { return {}; @@ -301,6 +326,14 @@ void ImeSession::apply_request_options(QueryRequest &request) const { request.enable_shuangpin_helpcode = enable_shuangpin_helpcode_; request.enable_mid_sentence_helpcode = enable_mid_sentence_helpcode_; + if (enable_direct_helpcode_ && request.scheme == SchemeType::Shuangpin) + { + // 直接辅助码接管辅码:末尾单码/双码那套判断会把辅码字母当成别的东西,关掉;约束由解析器 + // 改写请求时再打开。 + request.enable_shuangpin_helpcode = false; + request.enable_mid_sentence_helpcode = false; + request.enable_direct_helpcode = true; + } request.enable_quanpin_helpcode = enable_quanpin_helpcode_; request.enable_quanpin_autocorrect_transposition = (quanpin_autocorrect_types_ & quanpin::kAutocorrectTransposition) != 0; @@ -316,6 +349,7 @@ void ImeSession::refresh_candidates() state_.request = scheme_->build_request(); apply_request_options(state_.request); ApplyShuangpinHelpcodeSegmentation(state_.request, shuangpin_profile_); + resolve_direct_helpcode(state_.request); if (!state_.request.valid) { diff --git a/engine/core/ime_session.h b/engine/core/ime_session.h index babb701b..9dc78167 100644 --- a/engine/core/ime_session.h +++ b/engine/core/ime_session.h @@ -24,6 +24,13 @@ class ImeSession { enable_mid_sentence_helpcode_ = enabled; } + // 双拼直接辅助码(万象式,见 engine/direct_helpcode/)。开着时末尾单码/双码辅助和反引号句中 + // 辅助码都让位给它。 + void set_direct_helpcode_enabled(bool enabled); + bool direct_helpcode_enabled() const + { + return enable_direct_helpcode_; + } void set_quanpin_helpcode_enabled(bool enabled); void set_quanpin_autocorrect_types(unsigned autocorrect_types); void set_fuzzy_pinyin_options(metasequoia::FuzzyPinyinOptions options) @@ -96,8 +103,11 @@ class ImeSession // Shared option injection for refresh_candidates() and query_raw_candidates(); the two must // not drift or a prefix query would answer with different candidates than the live pipeline. void apply_request_options(QueryRequest &request) const; + // 直接辅助码开着时把请求交给解析器改写,见 ProviderRegistry::resolve_direct_helpcode。 + void resolve_direct_helpcode(QueryRequest &request); void refresh_candidates(); void bind_wubi_scheme(); + void bind_shuangpin_scheme(); std::unique_ptr create_scheme(SchemeType scheme_type) const; private: @@ -107,6 +117,7 @@ class ImeSession CompositionState state_; bool enable_shuangpin_helpcode_ = false; bool enable_mid_sentence_helpcode_ = false; + bool enable_direct_helpcode_ = false; bool enable_quanpin_helpcode_ = false; unsigned quanpin_autocorrect_types_ = 0; metasequoia::FuzzyPinyinOptions fuzzy_pinyin_; diff --git a/engine/core/input_session.cpp b/engine/core/input_session.cpp index 6ad1f9bd..26c68a87 100644 --- a/engine/core/input_session.cpp +++ b/engine/core/input_session.cpp @@ -1,6 +1,7 @@ #include "input_session.h" #include "../common/helpcode_utils.h" +#include "../contracts/direct_helpcode.h" #include "../local_modes/date_time_query.h" #include "../local_modes/emoji_query.h" #include "../local_modes/jianpin_query.h" @@ -169,11 +170,15 @@ KeyResult InputSession::handle_character(char character, bool shift_only) accepts_mid_sentence_helpcode_marker(); // 句中辅助码:反引号后的第一码大小写都收,紧跟的大写字母是第二码,与辅助码开关无关。 const bool mid_sentence_code = accepts_mid_sentence_code_at(get_pinyin_sequence_with_cases().size(), character); - const bool active_helpcode = character >= 'A' && character <= 'Z' && has_composition() && - ((scheme() == SchemeType::Quanpin && quanpin_helpcode_enabled_) || - (scheme() == SchemeType::Shuangpin && shuangpin_helpcode_enabled_)); + // 直接辅助码:辅码可以用大写敲(万象的大写变体),四码后接 / 终止。 + const bool direct_slash = character == '/' && has_composition() && + accepts_direct_helpcode_slash_at(get_pinyin_sequence_with_cases().size()); + const bool active_helpcode = + character >= 'A' && character <= 'Z' && has_composition() && + ((scheme() == SchemeType::Quanpin && quanpin_helpcode_enabled_) || + (scheme() == SchemeType::Shuangpin && (shuangpin_helpcode_enabled_ || direct_helpcode_enabled_))); if (!lowercase_letter && !active_helpcode && !mid_sentence_code && !mid_sentence_marker && character != '\'' && - !semicolon_final) + !semicolon_final && !direct_slash) { return {}; } @@ -185,6 +190,7 @@ KeyResult InputSession::handle_character(char character, bool shift_only) const std::string previous_preedit = preedit(); const auto unsigned_character = static_cast(character); const ImeKeyCode key_code = character == '\'' ? ImeKey::Apostrophe + : direct_slash ? ImeKey::Slash : mid_sentence_marker ? ImeKey::Backquote : semicolon_final ? ImeKey::Semicolon : static_cast(std::toupper(unsigned_character)); @@ -390,6 +396,29 @@ void InputSession::set_mid_sentence_helpcode_enabled(bool enabled) online_requests_.invalidate(); } +void InputSession::set_direct_helpcode_enabled(bool enabled) +{ + if (direct_helpcode_enabled_ == enabled) + { + return; + } + direct_helpcode_enabled_ = enabled; + engine_.set_direct_helpcode_enabled(enabled); + update_mixed_candidates(); + online_requests_.invalidate(); +} + +bool InputSession::accepts_direct_helpcode_slash_at(std::size_t caret) const +{ + if (!direct_helpcode_enabled_ || !is_shuangpin() || dedicated_english_mode_ || + local_input_mode_ != LocalInputMode::None) + { + return false; + } + const std::string &typed = get_pinyin_sequence_with_cases(); + return FanyImeDirectHelpcode::AcceptsSlashAt(typed.data(), typed.size(), caret); +} + bool InputSession::accepts_mid_sentence_helpcode_marker() const { return accepts_mid_sentence_helpcode_marker_at(caret_position()); @@ -397,7 +426,8 @@ bool InputSession::accepts_mid_sentence_helpcode_marker() const bool InputSession::accepts_mid_sentence_helpcode_marker_at(std::size_t caret) const { - if (!mid_sentence_helpcode_enabled_ || !is_shuangpin() || dedicated_english_mode_ || + // 直接辅助码开着时反引号不再是编码键:两套辅码同时生效,同一个音节会被约束两遍。 + if (!mid_sentence_helpcode_enabled_ || direct_helpcode_enabled_ || !is_shuangpin() || dedicated_english_mode_ || local_input_mode_ != LocalInputMode::None) { return false; @@ -408,8 +438,8 @@ bool InputSession::accepts_mid_sentence_helpcode_marker_at(std::size_t caret) co bool InputSession::accepts_mid_sentence_code_at(std::size_t caret, char character) const { const std::string &typed = get_pinyin_sequence_with_cases(); - if (!mid_sentence_helpcode_enabled_ || !is_shuangpin() || character < 'A' || character > 'Z' || caret == 0 || - caret > typed.size()) + if (!mid_sentence_helpcode_enabled_ || direct_helpcode_enabled_ || !is_shuangpin() || character < 'A' || + character > 'Z' || caret == 0 || caret > typed.size()) { return false; } @@ -420,8 +450,8 @@ bool InputSession::accepts_mid_sentence_code_at(std::size_t caret, char characte bool InputSession::has_mid_sentence_helpcode() const { - return mid_sentence_helpcode_enabled_ && !dedicated_english_mode_ && local_input_mode_ == LocalInputMode::None && - !request().syllable_helpcodes.empty(); + return (mid_sentence_helpcode_enabled_ || direct_helpcode_enabled_) && !dedicated_english_mode_ && + local_input_mode_ == LocalInputMode::None && !request().syllable_helpcodes.empty(); } std::vector InputSession::candidates_without_mid_sentence_helpcode() diff --git a/engine/core/input_session.h b/engine/core/input_session.h index 953f96fb..95bee61d 100644 --- a/engine/core/input_session.h +++ b/engine/core/input_session.h @@ -62,6 +62,16 @@ class InputSession // 仍按标点处理。不带参数的版本按会话自己的光标;光标由宿主维护时传宿主的光标。 bool accepts_mid_sentence_helpcode_marker() const; bool accepts_mid_sentence_helpcode_marker_at(std::size_t caret) const; + // 双拼直接辅助码(万象式,不要引导键,见 engine/direct_helpcode/)。默认关闭;开着时末尾辅助码 + // 和反引号句中辅助码都让位给它。 + void set_direct_helpcode_enabled(bool enabled); + bool direct_helpcode_enabled() const + { + return direct_helpcode_enabled_; + } + // 宿主在决定是否把 / 当作编码键(四码后的终止键)之前问这一句,规则见 + // FanyImeDirectHelpcode::AcceptsSlashAt。不满足时 / 仍按标点处理。 + bool accepts_direct_helpcode_slash_at(std::size_t caret) const; // 当前输入带着生效的句中辅助码约束:候选是筛过的,排位不能当调频的参照。 bool has_mid_sentence_helpcode() const; // 同一输入去掉句中辅助码约束后的引擎候选。用户下次不敲辅助码时看到的就是这份排序, @@ -341,6 +351,7 @@ class InputSession bool quanpin_helpcode_enabled_ = true; bool shuangpin_helpcode_enabled_ = true; bool mid_sentence_helpcode_enabled_ = false; + bool direct_helpcode_enabled_ = false; bool chinese_punctuation_enabled_ = true; bool candidate_learning_enabled_ = true; PunctuationPolicy punctuation_; diff --git a/engine/core/input_session_composition.cpp b/engine/core/input_session_composition.cpp index c6f616b0..1ba11bf9 100644 --- a/engine/core/input_session_composition.cpp +++ b/engine/core/input_session_composition.cpp @@ -91,10 +91,20 @@ struct MidSentenceRest std::string rest; }; -MidSentenceRest RemapMidSentenceRest(const std::string &typed, std::size_t clean_begin, std::size_t clean_end, - const ShuangpinProfile &profile) +MidSentenceRest RemapMidSentenceRest(const QueryRequest &request, const std::string &typed, std::size_t clean_begin, + std::size_t clean_end, const ShuangpinProfile &profile) { - const auto parsed = shuangpin::parse_mid_sentence_helpcodes(typed, profile); + // 直接辅助码的原串里没有反引号,切分是解析器整句解码选出来的,映射直接用它填好的那份。 + shuangpin::MidSentenceHelpcodeInput parsed; + if (request.direct_helpcode) + { + parsed.input = request.raw_input_with_cases; + parsed.source_index = request.direct_helpcode_source_index; + } + else + { + parsed = shuangpin::parse_mid_sentence_helpcodes(typed, profile); + } const std::string &clean = parsed.input; clean_end = (std::min)(clean_end, clean.size()); while (clean_begin < clean_end && clean[clean_begin] == '\'') @@ -522,10 +532,16 @@ std::string InputSession::build_pinyin_segmentation_with_cases(bool shuangpin_ra // 句中辅助码的反引号段在切分串里只是一个 ',显示时按原样接回对应音节后面; // 末尾补 ' 也要看用户敲的原串,否则 ulpb`x 会显示成 ul'pb`x'。 const std::string &typed = get_pinyin_sequence_with_cases(); + // 直接辅助码的辅码段由解析器给出,原串里没有反引号可解析。 + const auto decorate = [&](const std::string &segmentation) { + return request().direct_helpcode + ? shuangpin::decorate_segmentation(segmentation, request().direct_helpcode_decorations) + : shuangpin::decorate_mid_sentence_segmentation(segmentation, typed, shuangpin_profile_); + }; if (is_shuangpin() && shuangpin_raw) { std::string preedit = request().raw_segmentation.empty() ? request().raw_input : request().raw_segmentation; - preedit = shuangpin::decorate_mid_sentence_segmentation(preedit, typed, shuangpin_profile_); + preedit = decorate(preedit); if (!typed.empty() && typed.back() == '\'' && (preedit.empty() || preedit.back() != '\'')) { preedit.push_back('\''); @@ -540,7 +556,7 @@ std::string InputSession::build_pinyin_segmentation_with_cases(bool shuangpin_ra request().normalized_segmentation.empty() ? request().segmentation : request().normalized_segmentation; if (is_shuangpin()) { - preedit = shuangpin::decorate_mid_sentence_segmentation(preedit, typed, shuangpin_profile_); + preedit = decorate(preedit); } if (!typed.empty() && typed.back() == '\'' && (preedit.empty() || preedit.back() != '\'')) { @@ -788,7 +804,7 @@ InputSession::SelectionTransition InputSession::advance_composition_after_select remove_consumed_leading_separators(normalized_rest, cased_rest); if (!typed.empty()) { - auto remapped = RemapMidSentenceRest(typed, rest_start, rest_end, shuangpin_profile_); + auto remapped = RemapMidSentenceRest(request(), typed, rest_start, rest_end, shuangpin_profile_); transition.consumed_raw_input_with_cases = std::move(remapped.consumed); cased_rest = std::move(remapped.rest); normalized_rest = LowercaseLetters(cased_rest); @@ -821,8 +837,8 @@ InputSession::SelectionTransition InputSession::advance_composition_after_select remove_consumed_leading_separators(normalized_rest, cased_rest); if (!typed.empty()) { - auto remapped = RemapMidSentenceRest(typed, consumed_raw_length, base.raw_input_with_cases.size(), - shuangpin_profile_); + auto remapped = RemapMidSentenceRest(request(), typed, consumed_raw_length, + base.raw_input_with_cases.size(), shuangpin_profile_); transition.consumed_raw_input_with_cases = std::move(remapped.consumed); cased_rest = std::move(remapped.rest); normalized_rest = LowercaseLetters(cased_rest); diff --git a/engine/core/input_session_editing.cpp b/engine/core/input_session_editing.cpp index c95a15f8..4206e6a6 100644 --- a/engine/core/input_session_editing.cpp +++ b/engine/core/input_session_editing.cpp @@ -1,4 +1,5 @@ #include "input_session.h" +#include "../contracts/direct_helpcode.h" #include "../shuangpin/shuangpin_query.h" #include "../shuangpin/shuangpin_utils.h" #include @@ -95,7 +96,8 @@ std::vector InputSession::segment_raw_boundaries() const if (current_scheme_type() == SchemeType::Shuangpin) { // 带句中辅助码的串没有逐音节的单元模型:按段删除、按段跳光标和光标前缀重算都退回逐字符。 - if (shuangpin::has_mid_sentence_helpcode(raw_with_cases)) + // 直接辅助码同理:辅码字母混在音节之间,贪心切分算不出单元边界。 + if (shuangpin::has_mid_sentence_helpcode(raw_with_cases) || direct_helpcode_enabled_) { return {}; } @@ -193,14 +195,19 @@ KeyResult InputSession::insert_at_caret(char character) break; case LocalInputMode::None: accepted = lower || (upper && ((scheme() == SchemeType::Quanpin && quanpin_helpcode_enabled_) || - (scheme() == SchemeType::Shuangpin && shuangpin_helpcode_enabled_))); + (scheme() == SchemeType::Shuangpin && + (shuangpin_helpcode_enabled_ || direct_helpcode_enabled_)))); if (character == ';' && scheme() == SchemeType::Shuangpin && ShuangpinProfileUsesSemicolonFinal(shuangpin_profile_)) { const auto separator = caret == 0 ? std::string::npos : text.rfind('\'', caret - 1); const auto start = separator == std::string::npos ? 0 : separator + 1; - accepted = (caret - start) % 2 == 1; + accepted = direct_helpcode_enabled_ + ? FanyImeDirectHelpcode::AcceptsSemicolonFinalAt(text.data(), text.size(), caret) + : (caret - start) % 2 == 1; } + if (character == '/') + accepted = accepts_direct_helpcode_slash_at(caret); if (character == '\'' && scheme() != SchemeType::Wubi) accepted = caret > 0; // 光标移回句中补句中辅助码:反引号和它后面的码按光标前的部分判断。 diff --git a/engine/core/key_event.h b/engine/core/key_event.h index e2d7bf54..90536988 100644 --- a/engine/core/key_event.h +++ b/engine/core/key_event.h @@ -24,6 +24,7 @@ inline constexpr ImeKeyCode Escape = 0x1B; inline constexpr ImeKeyCode Space = 0x20; inline constexpr ImeKeyCode Semicolon = 0xBA; inline constexpr ImeKeyCode Minus = 0xBD; +inline constexpr ImeKeyCode Slash = 0xBF; // VK_OEM_2 inline constexpr ImeKeyCode Apostrophe = 0xDE; inline constexpr ImeKeyCode Backquote = 0xC0; // VK_OEM_3 } // namespace ImeKey diff --git a/engine/core/query_request.h b/engine/core/query_request.h index 8d502452..2501ecc1 100644 --- a/engine/core/query_request.h +++ b/engine/core/query_request.h @@ -5,7 +5,9 @@ #include "scheme_type.h" #include "sentence_association_options.h" #include "syllable_helpcode.h" +#include #include +#include #include struct KeyStroke @@ -33,6 +35,14 @@ struct QueryRequest bool enable_mid_sentence_helpcode = false; std::string raw_input_with_syllable_helpcodes; SyllableHelpcodes syllable_helpcodes; + // 双拼直接辅助码(见 engine/direct_helpcode/):解析器按整句解码选出的切分把请求改写成上面 + // 句中辅助码的形状。原串里没有反引号,下游没法从原串反推切分,所以去段串到原串的下标映射 + // (末尾多一项等于原串长度)和每个音节后面原样的辅码文本由解析器直接填在这里。 + bool direct_helpcode = false; + // 会话开着直接辅助码(不论这次请求有没有被改写)。词典层据此让词格与解析器共用一份跨度查询缓存。 + bool enable_direct_helpcode = false; + std::vector direct_helpcode_source_index; + std::vector> direct_helpcode_decorations; // Autocorrection is type-gated (bit0 transposition, bit1 neighbor in the session-level // mask); both default off, so a fresh install never rewrites the user's spelling. bool enable_quanpin_autocorrect_transposition = false; diff --git a/engine/direct_helpcode/direct_lattice.cpp b/engine/direct_helpcode/direct_lattice.cpp new file mode 100644 index 00000000..32d77e17 --- /dev/null +++ b/engine/direct_helpcode/direct_lattice.cpp @@ -0,0 +1,476 @@ +#include "direct_lattice.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace direct_helpcode +{ +namespace +{ +constexpr double kNegInf = -std::numeric_limits::infinity(); +// 搭配打分的上下文窗口,与 word_lattice.cpp 一致:.gram 的查询键最多编入 8 个码点。 +constexpr std::size_t kCollocationTailCodepoints = 8; + +std::vector split_utf8_chars(const std::string &text) +{ + std::vector chars; + for (std::size_t i = 0; i < text.size();) + { + const auto lead = static_cast(text[i]); + const std::size_t len = lead < 0x80 ? 1 : (lead >> 5) == 0x6 ? 2 : (lead >> 4) == 0xE ? 3 : 4; + chars.push_back(text.substr(i, len)); + i += len; + } + return chars; +} + +// 以下三个打分函数与 word_lattice.cpp 同义,见那边的注释。 +double heuristic_log_prob(std::int64_t weight, std::size_t syllables, const DecodeOptions &options) +{ + const double w = weight > 0 ? static_cast(weight) : 1.0; + const double z = options.unigram_z > 1.0 ? options.unigram_z : 1e6; + const double lp = std::log(w); + if (syllables <= 1) + return lp - std::log(z); + return lp + options.phrase_length_bonus * static_cast(syllables); +} + +double dictionary_tiebreak(std::int64_t weight, const DecodeOptions &options) +{ + const double w = weight > 0 ? static_cast(weight) : 1.0; + return options.dictionary_tiebreak * std::log10(w); +} + +double reading_prior(std::int64_t weight, std::int64_t span_total, const DecodeOptions &options) +{ + if (options.reading_prior <= 0 || span_total <= 0) + return 0; + const double w = weight > 0 ? static_cast(weight) : 0.0; + const double share = (w + 1.0) / (static_cast(span_total) + 1.0); + const double threshold = options.reading_prior_share > 0 ? options.reading_prior_share : 1.0; + if (share >= threshold) + return 0; + const double floor = options.reading_prior_floor > 0 ? -options.reading_prior_floor : kNegInf; + return options.reading_prior * (std::max)(std::log10(share / threshold), floor); +} + +const ngram::LanguageModel *active_model(const DecodeOptions &options) +{ + if (options.language_model && options.language_model->valid()) + return options.language_model; + return nullptr; +} + +struct WordEdge +{ + std::size_t end = 0; + std::string word; + std::string key; + double base_score = 0; + ngram::WordIndex index = 0; + // 占位边:缩写声母或查不到字的音节,只为让切分走得通,不进语言模型。 + bool placeholder = false; + std::vector spellings; +}; + +class WordGraphBuilder +{ + public: + WordGraphBuilder(const SpellingGraph &graph, const SpanLookup &lookup, const CharAccept &accept, + const DecodeOptions &options, WordEdgeMemo *memo) + : graph_(graph), lookup_(lookup), accept_(accept), options_(options), model_(active_model(options)), memo_(memo) + { + } + + std::vector> build() + { + std::vector> words(graph_.size); + for (std::size_t start = 0; start < graph_.size; ++start) + { + if (graph_.edges[start].empty()) + continue; + sequences_ = 0; + std::vector sequence; + extend(start, sequence, words[start]); + } + return words; + } + + private: + void extend(std::size_t pos, std::vector &sequence, std::vector &out) + { + if (pos >= graph_.size) + return; + for (const auto &spelling : graph_.edges[pos]) + { + if (sequences_ >= options_.max_sequences_per_start) + return; + if (spelling.kind == SpellingKind::Initial) + { + // 声母缩写只单独成边,不拼进多字词:词格查的是完整读音。 + if (sequence.empty()) + out.push_back(placeholder(spelling)); + continue; + } + ++sequences_; + sequence.push_back(&spelling); + emit(sequence, out); + if (static_cast(sequence.size()) < options_.max_phrase_syllables) + extend(next_spelling_position(graph_, spelling.end), sequence, out); + sequence.pop_back(); + } + } + + WordEdge placeholder(const SyllableSpelling &spelling) const + { + WordEdge edge; + edge.end = spelling.end; + edge.placeholder = true; + edge.base_score = options_.initial_penalty; + edge.spellings.push_back(&spelling); + return edge; + } + + const std::vector &rows_for(const quanpin::Segments &span, bool constrained) + { + std::string key = quanpin::join_segments(span); + if (constrained) + key.push_back('#'); + auto found = cache_.find(key); + if (found == cache_.end()) + found = cache_ + .emplace(std::move(key), + lookup_ ? lookup_(span, constrained) : std::vector{}) + .first; + return found->second; + } + + // 这条拼写序列铺出来的词只取决于各音节的读音与辅码,跨按键记在 memo 里。 + static std::string memo_key(const std::vector &sequence) + { + std::string key; + for (const auto *spelling : sequence) + { + key += spelling->quanpin; + key.push_back(spelling->first ? spelling->first : '-'); + key.push_back(spelling->second ? spelling->second : '-'); + key.push_back('|'); + } + return key; + } + + void emit(const std::vector &sequence, std::vector &out) + { + const std::vector *words = nullptr; + std::vector local; + if (memo_) + { + std::string key = memo_key(sequence); + auto found = memo_->find(key); + if (found == memo_->end()) + found = memo_->emplace(std::move(key), lookup_words(sequence)).first; + words = &found->second; + } + else + { + local = lookup_words(sequence); + words = &local; + } + + if (words->empty()) + { + // 单个音节查不到字时也留一条占位边,免得整条切分断掉;分数比声母缩写还低。 + if (sequence.size() == 1) + { + WordEdge edge = placeholder(*sequence.front()); + edge.base_score = options_.initial_penalty * 2; + out.push_back(std::move(edge)); + } + return; + } + for (const auto &word : *words) + { + WordEdge edge; + edge.end = sequence.back()->end; + edge.word = word.word; + edge.key = word.key; + edge.base_score = word.base_score; + edge.index = word.index; + edge.spellings = sequence; + out.push_back(std::move(edge)); + } + } + + std::vector lookup_words(const std::vector &sequence) + { + quanpin::Segments span; + bool constrained = false; + for (const auto *spelling : sequence) + { + span.push_back(spelling->quanpin); + constrained = constrained || spelling->has_aux(); + } + const auto &rows = rows_for(span, constrained); + + std::vector accepted; + for (const auto &row : rows) + { + if (static_cast(accepted.size()) >= options_.span_limit) + break; + if (row.value.empty()) + continue; + const auto chars = split_utf8_chars(row.value); + if (chars.size() != sequence.size()) + continue; + bool ok = true; + for (std::size_t i = 0; ok && i < sequence.size(); ++i) + { + if (sequence[i]->has_aux()) + ok = accept_ && accept_(chars[i], sequence[i]->first, sequence[i]->second); + } + if (ok) + accepted.push_back(&row); + } + + std::vector words; + std::int64_t span_total = 0; + for (const auto *row : accepted) + span_total += row->weight > 0 ? row->weight : 0; + const std::string span_key = quanpin::join_segments(span); + for (const auto *row : accepted) + { + CachedWord word; + word.word = row->value; + word.key = row->key.empty() ? span_key : row->key; + if (model_) + { + word.index = model_->index(word.word); + word.base_score = + dictionary_tiebreak(row->weight, options_) + reading_prior(row->weight, span_total, options_); + } + else + { + word.base_score = heuristic_log_prob(row->weight, sequence.size(), options_); + } + words.push_back(std::move(word)); + } + return words; + } + + const SpellingGraph &graph_; + const SpanLookup &lookup_; + const CharAccept &accept_; + const DecodeOptions &options_; + const ngram::LanguageModel *model_; + WordEdgeMemo *memo_; + std::unordered_map> cache_; + int sequences_ = 0; +}; + +// 搭配打分的尾窗。每条状态转移都要生成一个新尾窗,用 std::string 时每次都是堆分配(拼接一次、截断 +// 一次、存进假设一次),是解码的大头。合法 UTF-8 下 8 个码点至多 32 字节,放在定长缓冲里;词库行 +// 不保证是合法 UTF-8,真超出时退回 std::string。截断规则与 word_lattice.cpp 的 tail_codepoints 完全一致。 +class CollocationTail +{ + public: + std::string_view view() const + { + return overflow_.empty() ? std::string_view(bytes_.data(), size_) : std::string_view(overflow_); + } + + // 等价于 word_lattice.cpp 的 tail_codepoints(view() + word, kCollocationTailCodepoints)。 + CollocationTail extended(std::string_view word) const + { + const std::string_view tail = view(); + const std::size_t total = tail.size() + word.size(); + CollocationTail next; + if (total <= kScratch) + { + std::array scratch; + std::copy(tail.begin(), tail.end(), scratch.begin()); + std::copy(word.begin(), word.end(), scratch.begin() + static_cast(tail.size())); + next.assign(keep_tail(std::string_view(scratch.data(), total))); + } + else + { + const std::string joined = std::string(tail) + std::string(word); + next.assign(keep_tail(joined)); + } + return next; + } + + private: + static constexpr std::size_t kCapacity = 48; + static constexpr std::size_t kScratch = 160; + + // tail_codepoints 的同一套规则:数非续字节当码点,从头跳过多出来的码点。 + static std::string_view keep_tail(std::string_view text) + { + std::size_t total = 0; + for (unsigned char c : text) + { + if ((c & 0xC0) != 0x80) + ++total; + } + if (total <= kCollocationTailCodepoints) + return text; + std::size_t skip = total - kCollocationTailCodepoints; + std::size_t i = 0; + while (i < text.size() && skip > 0) + { + ++i; + --skip; + while (i < text.size() && (static_cast(text[i]) & 0xC0) == 0x80) + ++i; + } + return text.substr(i); + } + + void assign(std::string_view text) + { + if (text.size() <= kCapacity) + { + std::copy(text.begin(), text.end(), bytes_.begin()); + size_ = static_cast(text.size()); + overflow_.clear(); + } + else + { + size_ = 0; + overflow_.assign(text); + } + } + + std::array bytes_{}; + std::uint8_t size_ = 0; + std::string overflow_; +}; + +struct Hyp +{ + double score = kNegInf; + int prev_pos = -1; + int prev_idx = -1; + // 走到这个假设的那条边;分隔符直通时为空。 + const WordEdge *edge = nullptr; + ngram::State state; + CollocationTail collocation_tail; +}; + +void keep_beam(std::vector &column, int beam) +{ + if (static_cast(column.size()) <= beam) + return; + std::partial_sort(column.begin(), column.begin() + beam, column.end(), + [](const Hyp &a, const Hyp &b) { return a.score > b.score; }); + column.resize(static_cast(beam)); +} +} // namespace + +std::optional decode_best_path(const SpellingGraph &graph, const SpanLookup &lookup, + const CharAccept &accept, const DecodeOptions &options, WordEdgeMemo *memo) +{ + if (graph.empty() || graph.size == 0) + return std::nullopt; + + const std::size_t n = graph.size; + const ngram::LanguageModel *model = active_model(options); + const bool collocation = options.collocation_scorer && options.collocation_weight > 0.0; + const auto words = WordGraphBuilder(graph, lookup, accept, options, memo).build(); + + std::vector> columns(n + 1); + Hyp start; + start.score = 0.0; + // 与 decode_word_lattice 一样从空上下文起步,见那边的注释。 + if (model) + start.state = model->null_state(); + columns[0].push_back(std::move(start)); + + for (std::size_t pos = 0; pos < n; ++pos) + { + keep_beam(columns[pos], options.beam); + if (columns[pos].empty()) + continue; + for (int hi = 0; hi < static_cast(columns[pos].size()); ++hi) + { + const Hyp &hyp = columns[pos][static_cast(hi)]; + if (graph.skip[pos]) + { + Hyp next = hyp; + next.prev_pos = static_cast(pos); + next.prev_idx = hi; + next.edge = nullptr; + columns[pos + 1].push_back(std::move(next)); + } + for (const auto &edge : words[pos]) + { + Hyp next; + next.score = hyp.score + edge.base_score; + if (edge.placeholder || !model) + next.state = hyp.state; + // 关闭搭配项时尾窗始终为空,不为每个假设白拷一次。 + if (collocation && edge.placeholder) + next.collocation_tail = hyp.collocation_tail; + if (!edge.placeholder) + { + if (model) + next.score += model->score(hyp.state, edge.index, next.state); + if (collocation) + { + const bool is_rear = next_spelling_position(graph, edge.end) == n; + next.score += options.collocation_weight * + options.collocation_scorer(hyp.collocation_tail.view(), edge.word, is_rear); + next.collocation_tail = hyp.collocation_tail.extended(edge.word); + } + } + next.prev_pos = static_cast(pos); + next.prev_idx = hi; + next.edge = &edge; + columns[edge.end].push_back(std::move(next)); + } + } + } + + const auto &final_col = columns[n]; + if (final_col.empty()) + return std::nullopt; + const auto best = std::max_element(final_col.begin(), final_col.end(), + [](const Hyp &a, const Hyp &b) { return a.score < b.score; }); + + DecodedPath path; + path.log_prob = best->score; + std::vector edges; + int pos = static_cast(n); + int idx = static_cast(std::distance(final_col.begin(), best)); + while (pos > 0 && idx >= 0) + { + const Hyp &hyp = columns[static_cast(pos)][static_cast(idx)]; + if (hyp.edge) + edges.push_back(hyp.edge); + pos = hyp.prev_pos; + idx = hyp.prev_idx; + } + std::reverse(edges.begin(), edges.end()); + for (const auto *edge : edges) + { + path.complete = path.complete && !edge->placeholder; + path.sentence += edge->word; + if (!edge->placeholder) + { + if (!path.key.empty()) + path.key.push_back('\''); + path.key += edge->key; + path.words.push_back(edge->word); + } + for (const auto *spelling : edge->spellings) + path.syllables.push_back(*spelling); + } + return path; +} + +} // namespace direct_helpcode diff --git a/engine/direct_helpcode/direct_lattice.h b/engine/direct_helpcode/direct_lattice.h new file mode 100644 index 00000000..51fc8806 --- /dev/null +++ b/engine/direct_helpcode/direct_lattice.h @@ -0,0 +1,76 @@ +#pragma once + +#include "spelling_graph.h" + +#include "../quanpin/quanpin_query.h" + +#include +#include +#include +#include +#include + +// 直接辅助码的整句解码:在按键位置上的拼写图里铺词,一次 Viterbi 同时决定切分与用字。 +// +// 和 quanpin::decode_word_lattice 用同一套打分(kenlm、词库权重平局、生僻读音先验、octagram +// 搭配项),区别只在图:那边的节点是固定切分的音节序号,这里是输入串的按键位置,同一段按键 +// 的不同切法(uia|uiq 与 ui|au|iq)各自成边,由整句分数裁决。这就是 librime 在万象里做的事。 +// 打分函数是从 word_lattice.cpp 抄过来的,为的是不动全拼的解码器。 +namespace direct_helpcode +{ + +// 按全拼跨度查词。constrained 为真时跨度里有辅码约束,查询不能在 SQL 里先截断(生僻字会被 +// 截没),由解码器筛完约束再截。 +using SpanLookup = std::function(const quanpin::Segments &span, bool constrained)>; + +// 这个汉字(UTF-8 单字)的辅码满不满足 first/second(second 为 0 只看第一码)。 +using CharAccept = std::function; + +struct DecodeOptions +{ + int beam = 32; + int span_limit = 32; + int max_phrase_syllables = 7; + // 每个起点最多展开多少条拼写序列,防止长串上组合爆炸。 + int max_sequences_per_start = 256; + const ngram::LanguageModel *language_model = nullptr; + double dictionary_tiebreak = 0.01; + double reading_prior = 1.0; + double reading_prior_share = 0.01; + double reading_prior_floor = 5.0; + double unigram_z = 1e6; + double phrase_length_bonus = 3.0; + // 单个声母(缩写)占位边的分数:没有字可出,只让切分走得通,任何真正出字的切法都比它好。 + double initial_penalty = -12.0; + quanpin::LatticeCollocationScorer collocation_scorer; + double collocation_weight = 0.0; +}; + +struct DecodedPath +{ + std::vector syllables; + std::string sentence; + // 全拼键(' 分隔)与逐词文本,和 quanpin::LatticePath 同义,供词格合并直接复用。 + std::string key; + std::vector words; + double log_prob = 0; + // 路径上有占位边(声母缩写、查不到字的音节)时整句不完整,不能当整句候选。 + bool complete = true; +}; + +// 一条拼写序列(音节 + 辅码)铺出来的词,跨按键复用:多敲一个键时前面那些序列的查词、辅码筛选 +// 和打分都不变。只和词库、码表、打分选项有关,三者变了由调用方清空。 +struct CachedWord +{ + std::string word; + std::string key; + double base_score = 0; + ngram::WordIndex index = 0; +}; +using WordEdgeMemo = std::unordered_map>; + +std::optional decode_best_path(const SpellingGraph &graph, const SpanLookup &lookup, + const CharAccept &accept, const DecodeOptions &options = {}, + WordEdgeMemo *memo = nullptr); + +} // namespace direct_helpcode diff --git a/engine/direct_helpcode/direct_resolver.cpp b/engine/direct_helpcode/direct_resolver.cpp new file mode 100644 index 00000000..e07af62a --- /dev/null +++ b/engine/direct_helpcode/direct_resolver.cpp @@ -0,0 +1,287 @@ +#include "direct_resolver.h" + +#include "../quanpin/quanpin_query.h" +#include "../shuangpin/shuangpin_query.h" + +#include +#include + +namespace direct_helpcode +{ +namespace +{ +// 缓存条目上限:超过就整体清掉,免得一次长时间输入把内存撑大。 +constexpr std::size_t kSpanCacheLimit = 8192; +constexpr std::size_t kResolutionCacheLimit = 256; +constexpr std::size_t kCollocationMemoLimit = 65536; + +std::string lowercase(std::string text) +{ + for (char &ch : text) + ch = static_cast(std::tolower(static_cast(ch))); + return text; +} + +bool is_single_utf8_char(const std::string &text) +{ + if (text.empty()) + return false; + const auto lead = static_cast(text[0]); + const std::size_t len = lead < 0x80 ? 1 : (lead >> 5) == 0x6 ? 2 : (lead >> 4) == 0xE ? 3 : 4; + return text.size() == len; +} + +bool accepts_char(const HelpcodeUtils::Keymap *keymap, const std::string &hanzi, char first, char second) +{ + if (keymap == nullptr) + return false; + const auto found = keymap->find(hanzi); + if (found == keymap->end()) + return false; + SyllableHelpcode helpcode; + helpcode.first = first; + helpcode.second = second; + return syllable_helpcode_matches(helpcode, found->second); +} + +// 两个相邻音节之间要不要放一个手动分隔符:前一个音节后面挂着辅码、任一个是单键声母、或原串里 +// 两者之间本来就隔着别的字符时都要放。其余情况是连续的两键音节,贪心切分自己就切得出来,不放 +// 分隔符正是为了让它们和普通双拼走完全相同的路径。 +bool needs_delimiter(const SyllableSpelling &previous, const SyllableSpelling ¤t) +{ + return previous.end != previous.begin + previous.code.size() || previous.kind == SpellingKind::Initial || + current.kind == SpellingKind::Initial || current.begin != previous.end; +} +} // namespace + +void apply_spellings(QueryRequest &request, const std::string &typed, const std::vector &spellings, + const ShuangpinProfile &profile) +{ + std::string clean; + std::vector source_index; + std::vector> decorations; + SyllableHelpcodes helpcodes; + for (std::size_t i = 0; i < spellings.size(); ++i) + { + const auto &spelling = spellings[i]; + if (i > 0 && needs_delimiter(spellings[i - 1], spelling)) + { + const auto &previous = spellings[i - 1]; + clean.push_back('\''); + // 和句中辅助码一致:分隔符对应前一个音节辅码段的起点。 + source_index.push_back(previous.begin + previous.code.size()); + } + for (std::size_t j = 0; j < spelling.code.size(); ++j) + { + clean.push_back(spelling.code[j]); + source_index.push_back(spelling.begin + j); + } + const std::size_t code_end = spelling.begin + spelling.code.size(); + if (spelling.end > code_end) + decorations.emplace_back(i, typed.substr(code_end, spelling.end - code_end)); + if (spelling.has_aux()) + { + SyllableHelpcode helpcode; + helpcode.syllable = i; + helpcode.first = spelling.first; + helpcode.second = spelling.second; + helpcodes.push_back(helpcode); + } + } + source_index.push_back(typed.size()); + + request.raw_input_with_syllable_helpcodes = typed; + request.raw_input = clean; + request.raw_input_with_cases = clean; + request.syllable_helpcodes = std::move(helpcodes); + request.enable_mid_sentence_helpcode = true; + request.enable_shuangpin_helpcode = false; + request.direct_helpcode = true; + request.direct_helpcode_source_index = std::move(source_index); + request.direct_helpcode_decorations = std::move(decorations); + request.valid = !clean.empty(); + if (!request.valid) + return; + const std::string raw_segmentation = shuangpin::segment_input(clean, profile); + request.raw_segmentation = raw_segmentation; + request.normalized_segmentation = shuangpin::to_quanpin_segmentation(raw_segmentation, profile); + request.segmentation = request.normalized_segmentation; + request.normalized_input = shuangpin::normalize_input(clean, profile); +} + +Resolver::Resolver(const ShuangpinProfile &profile) : profile_(profile) +{ +} + +void Resolver::reset_cache() +{ + span_cache_.clear(); + word_memo_.clear(); + collocation_memo_.clear(); + resolution_cache_.clear(); + last_sentence_ = nullptr; +} + +void Resolver::build_aux_index(const ResolveContext &context) +{ + aux_index_built_ = true; + if (!context.single_char_rows || context.keymap == nullptr) + return; + // 一次扫完词库的单字行,对应万象词库里为每个字派生的 拼音;辅码 拼写。比按音节逐个查(每个音节一条 + // 4096 行的查询)便宜得多,也不会把第一次碰到某个音节的那一键拖慢。 + for (const auto &[quanpin, hanzi] : context.single_char_rows()) + { + const auto code = context.keymap->find(hanzi); + if (code == context.keymap->end() || code->second.empty()) + continue; + auto &aux = aux_index_[quanpin]; + const char lead = code->second[0]; + if (lead >= 'a' && lead <= 'z') + aux.first[static_cast(lead - 'a')] = true; + if (code->second.size() > 1) + aux.pairs.insert(code->second.substr(0, 2)); + } +} + +const std::vector &Resolver::span_rows(const quanpin::Segments &span, bool constrained, + const ResolveContext &context) +{ + std::string key = quanpin::join_segments(span); + if (constrained) + key.push_back('#'); + auto found = span_cache_.find(key); + if (found != span_cache_.end()) + return found->second; + if (span_cache_.size() >= kSpanCacheLimit) + span_cache_.clear(); + auto rows = context.lookup ? context.lookup(span, constrained) : std::vector{}; + return span_cache_.emplace(std::move(key), std::move(rows)).first->second; +} + +bool Resolver::has_aux(const std::string &quanpin, char first, char second, const ResolveContext &context) +{ + if (context.keymap == nullptr || first < 'a' || first > 'z') + return false; + if (!aux_index_built_) + build_aux_index(context); + auto found = aux_index_.find(quanpin); + if (found == aux_index_.end()) + { + // 全量扫描里没有这个读音(没有全量扫描,或词库键的写法不同,如 ü):退回按音节查一次,结果同样 + // 记下,之后不再查。 + SyllableAux aux; + for (const auto &row : span_rows({quanpin}, true, context)) + { + if (!is_single_utf8_char(row.value)) + continue; + const auto code = context.keymap->find(row.value); + if (code == context.keymap->end() || code->second.empty()) + continue; + const char lead = code->second[0]; + if (lead >= 'a' && lead <= 'z') + aux.first[static_cast(lead - 'a')] = true; + if (code->second.size() > 1) + aux.pairs.insert(code->second.substr(0, 2)); + } + found = aux_index_.emplace(quanpin, std::move(aux)).first; + } + if (second == 0) + return found->second.first[static_cast(first - 'a')]; + return found->second.pairs.count(std::string{first, second}) > 0; +} + +bool Resolver::resolve(QueryRequest &request, const ResolveContext &context) +{ + const std::string typed = request.raw_input_with_cases.empty() ? request.raw_input : request.raw_input_with_cases; + if (typed.empty()) + return false; + last_sentence_ = nullptr; + if (context.keymap != indexed_keymap_) + { + reset_cache(); + aux_index_.clear(); + aux_index_built_ = false; + indexed_keymap_ = context.keymap; + } + + auto cached = resolution_cache_.find(typed); + if (cached == resolution_cache_.end()) + { + const auto graph = + build_spelling_graph(typed, profile_, [&](const std::string &quanpin, char first, char second) { + return has_aux(quanpin, first, second, context); + }); + Resolution resolution; + // 只有一条完整切分时没有可比的切法,跳过整句解码;整句留给词典层自己的词格去解。 + if (!graph.empty() && !single_path(graph, resolution.spellings)) + { + const SpanLookup lookup = [&](const quanpin::Segments &span, bool constrained) { + return span_rows(span, constrained, context); + }; + const CharAccept accept = [&](const std::string &hanzi, char first, char second) { + return accepts_char(context.keymap, hanzi, first, second); + }; + if (word_memo_.size() >= kSpanCacheLimit) + word_memo_.clear(); + // 搭配分只取决于(尾窗、词、是否句尾),是纯函数:记下来跨假设、跨按键复用,结果与不缓存完全一致。 + // 解析器一次要比较所有切法,边数远多于单一切分的词格,不缓存时 .gram 查询是解码的大头。 + DecodeOptions options = context.options; + if (options.collocation_scorer && options.collocation_weight > 0.0) + { + options.collocation_scorer = [this, &context](std::string_view tail, std::string_view word, + bool is_rear) { + // 复用同一个键缓冲:命中时不分配,只有未命中插入时才拷一份。 + std::string &key = collocation_key_; + key.clear(); + key.append(tail).push_back('\x1f'); + key.append(word).push_back(is_rear ? '1' : '0'); + auto found = collocation_memo_.find(key); + if (found != collocation_memo_.end()) + return found->second; + if (collocation_memo_.size() >= kCollocationMemoLimit) + collocation_memo_.clear(); + const double score = context.options.collocation_scorer(tail, word, is_rear); + collocation_memo_.emplace(key, score); + return score; + }; + } + if (auto path = decode_best_path(graph, lookup, accept, options, &word_memo_)) + { + resolution.spellings = path->syllables; + if (path->complete && !path->sentence.empty()) + resolution.sentence = std::move(path); + } + } + if (resolution_cache_.size() >= kResolutionCacheLimit) + resolution_cache_.clear(); + cached = resolution_cache_.emplace(typed, std::move(resolution)).first; + } + const auto &spellings = cached->second.spellings; + if (spellings.empty()) + return false; + if (cached->second.sentence) + last_sentence_ = &*cached->second.sentence; + + // 没有辅码、也没有吃掉分隔符以外的字符时,选中的切分就是普通双拼的切分:请求原样放行, + // 不带辅码的输入和关着开关时走完全一样的路。 + const bool plain = std::none_of(spellings.begin(), spellings.end(), [](const SyllableSpelling &spelling) { + return spelling.end != spelling.begin + spelling.code.size(); + }); + if (plain) + { + std::string joined; + for (std::size_t i = 0; i < spellings.size(); ++i) + { + if (i > 0) + joined.push_back('\''); + joined += spellings[i].code; + } + if (joined == shuangpin::segment_input(lowercase(typed), profile_)) + return false; + } + + apply_spellings(request, typed, spellings, profile_); + return true; +} + +} // namespace direct_helpcode diff --git a/engine/direct_helpcode/direct_resolver.h b/engine/direct_helpcode/direct_resolver.h new file mode 100644 index 00000000..15b9c4c0 --- /dev/null +++ b/engine/direct_helpcode/direct_resolver.h @@ -0,0 +1,88 @@ +#pragma once + +#include "direct_lattice.h" + +#include "../common/helpcode_utils.h" +#include "../core/query_request.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +// 双拼直接辅助码的入口:把用户敲的原串(uiauiq)整句解码,选出切分,再把请求改写成句中辅助码 +// 的形状(ui'ui + 每个音节上的约束),之后的查词、整句、选词推进、预编辑全部复用句中辅助码那条 +// 已有的路径。规则见 spelling_graph.h,开关关着时这里一行都不会跑。 +namespace direct_helpcode +{ + +// 全拼读音 + 单字,词库里所有单字行,建辅码索引用。 +using SingleCharRows = std::vector>; + +struct ResolveContext +{ + SpanLookup lookup; + // 一次取出词库里全部单字行。为空时退回按音节逐个查。 + std::function single_char_rows; + // 当前辅助码方案的码表;为空时没有辅码边,整串按普通双拼解。 + const HelpcodeUtils::Keymap *keymap = nullptr; + DecodeOptions options; +}; + +class Resolver +{ + public: + explicit Resolver(const ShuangpinProfile &profile); + + // 改写了请求返回 true。选中的切分里没有辅码、和普通双拼切得一样时不改写,那时请求本来就对。 + bool resolve(QueryRequest &request, const ResolveContext &context); + // 上一次 resolve 顺带解出的最优整句;只有一条切分(没解码)或路径不完整时为空。下一次 resolve 前有效。 + const DecodedPath *resolved_sentence() const + { + return last_sentence_; + } + // 词库或整句打分选项变了,查词与解码结果作废。辅码索引只随码表变:它只依赖单字读音和码表, + // 用户造词几乎不会新增单字,选词调频时不必重建。 + void reset_cache(); + + private: + struct SyllableAux + { + std::array first{}; + std::unordered_set pairs; + }; + struct Resolution + { + std::vector spellings; + std::optional sentence; + }; + + void build_aux_index(const ResolveContext &context); + bool has_aux(const std::string &quanpin, char first, char second, const ResolveContext &context); + const std::vector &span_rows(const quanpin::Segments &span, bool constrained, + const ResolveContext &context); + + const ShuangpinProfile profile_; + const HelpcodeUtils::Keymap *indexed_keymap_ = nullptr; + bool aux_index_built_ = false; + std::unordered_map aux_index_; + // 跨按键复用的跨度查询:每敲一个键整串重新解码,前面那些跨度的行没有变。 + std::unordered_map> span_cache_; + // 跨按键复用的词边:见 WordEdgeMemo。 + WordEdgeMemo word_memo_; + // 搭配分(尾窗 + 词 + 是否句尾 → 分数),整句打分选项变了随 reset_cache 一起清。 + std::unordered_map collocation_memo_; + std::string collocation_key_; + std::unordered_map resolution_cache_; + const DecodedPath *last_sentence_ = nullptr; +}; + +// 按选中的切分改写请求,纯函数,单测直接用。typed 是原串(保留大小写)。 +void apply_spellings(QueryRequest &request, const std::string &typed, const std::vector &spellings, + const ShuangpinProfile &profile); + +} // namespace direct_helpcode diff --git a/engine/direct_helpcode/spelling_graph.cpp b/engine/direct_helpcode/spelling_graph.cpp new file mode 100644 index 00000000..4da51124 --- /dev/null +++ b/engine/direct_helpcode/spelling_graph.cpp @@ -0,0 +1,207 @@ +#include "spelling_graph.h" + +#include "../shuangpin/shuangpin_utils.h" + +#include + +namespace direct_helpcode +{ +namespace +{ +bool is_letter(char ch) +{ + return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z'); +} + +char to_lower(char ch) +{ + return ch >= 'A' && ch <= 'Z' ? static_cast(ch + ('a' - 'A')) : ch; +} + +// 两键是不是一个完整音节;是就返回它的全拼。第二键可以是微软/搜狗双拼的 ; 韵母。 +std::string syllable_quanpin(const std::string &code, const ShuangpinProfile &profile) +{ + if (!ShuangpinUtil::is_accepted_syllable_code(code, profile)) + return {}; + return ShuangpinUtil::cvt_single_sp_to_pinyin(code, profile); +} + +SyllableSpelling make_spelling(std::size_t begin, std::size_t end, std::string code, std::string quanpin, + SpellingKind kind, char first = 0, char second = 0) +{ + SyllableSpelling spelling; + spelling.begin = begin; + spelling.end = end; + spelling.code = std::move(code); + spelling.quanpin = std::move(quanpin); + spelling.first = first; + spelling.second = second; + spelling.kind = kind; + return spelling; +} + +std::vector forward_reach(const SpellingGraph &graph, bool normal_only) +{ + std::vector reach(graph.size + 1, false); + reach[0] = true; + for (std::size_t pos = 0; pos < graph.size; ++pos) + { + if (!reach[pos]) + continue; + if (graph.skip[pos]) + reach[pos + 1] = true; + for (const auto &spelling : graph.edges[pos]) + { + if (!normal_only || !spelling.is_abbreviation()) + reach[spelling.end] = true; + } + } + return reach; +} + +SpellingGraph build_attempt(const std::string &typed, const ShuangpinProfile &profile, const AuxPredicate &has_aux, + bool symbols_as_delimiters) +{ + SpellingGraph graph; + const std::size_t n = typed.size(); + graph.size = n; + graph.edges.resize(n); + graph.skip.resize(n, false); + for (std::size_t pos = 0; pos < n; ++pos) + { + const char ch = typed[pos]; + graph.skip[pos] = ch == '\'' || (symbols_as_delimiters && (ch == '/' || ch == ';')); + if (!is_letter(ch)) + continue; + + auto &out = graph.edges[pos]; + if (pos + 1 < n && (is_letter(typed[pos + 1]) || typed[pos + 1] == ';')) + { + std::string code{to_lower(ch), to_lower(typed[pos + 1])}; + std::string quanpin = syllable_quanpin(code, profile); + if (!quanpin.empty()) + { + out.push_back(make_spelling(pos, pos + 2, code, quanpin, SpellingKind::Plain)); + if (pos + 2 < n && is_letter(typed[pos + 2])) + { + const char first = to_lower(typed[pos + 2]); + if (has_aux && has_aux(quanpin, first, 0)) + out.push_back(make_spelling(pos, pos + 3, code, quanpin, SpellingKind::Aux1, first)); + if (pos + 3 < n && is_letter(typed[pos + 3])) + { + const char second = to_lower(typed[pos + 3]); + if (has_aux && has_aux(quanpin, first, second)) + { + if (pos + 4 < n && typed[pos + 4] == '/') + out.push_back( + make_spelling(pos, pos + 5, code, quanpin, SpellingKind::Aux2Slash, first, second)); + out.push_back( + make_spelling(pos, pos + 4, code, quanpin, SpellingKind::Aux2Abbrev, first, second)); + } + } + } + } + } + const std::string initial(1, to_lower(ch)); + out.push_back(make_spelling(pos, pos + 1, initial, + ShuangpinUtil::convert_seg_shuangpin_to_seg_complete_pinyin(initial, profile), + SpellingKind::Initial)); + } + + // librime:存在一条全由非缩写拼写走到末尾的路径时,缩写边全部作废。 + if (forward_reach(graph, true)[n]) + { + for (auto &out : graph.edges) + { + out.erase(std::remove_if(out.begin(), out.end(), + [](const SyllableSpelling &spelling) { return spelling.is_abbreviation(); }), + out.end()); + } + } + + const auto reach = forward_reach(graph, false); + if (!reach[n]) + return {}; + // 只留落在完整路径上的边:词格只需要能走到末尾的那些。 + std::vector to_end(n + 1, false); + to_end[n] = true; + for (std::size_t pos = n; pos-- > 0;) + { + bool ok = graph.skip[pos] && to_end[pos + 1]; + for (const auto &spelling : graph.edges[pos]) + ok = ok || to_end[spelling.end]; + to_end[pos] = ok; + } + for (std::size_t pos = 0; pos < n; ++pos) + { + auto &out = graph.edges[pos]; + if (!reach[pos]) + { + out.clear(); + continue; + } + out.erase(std::remove_if(out.begin(), out.end(), + [&](const SyllableSpelling &spelling) { return !to_end[spelling.end]; }), + out.end()); + } + return graph; +} +} // namespace + +SpellingGraph build_spelling_graph(const std::string &typed, const ShuangpinProfile &profile, + const AuxPredicate &has_aux) +{ + if (typed.empty()) + return {}; + SpellingGraph graph = build_attempt(typed, profile, has_aux, false); + if (graph.empty()) + { + // 用不上的 / 和 ; 退回成分隔符,候选照常出,不至于整串作废。 + graph = build_attempt(typed, profile, has_aux, true); + } + return graph; +} + +std::size_t next_spelling_position(const SpellingGraph &graph, std::size_t pos) +{ + while (pos < graph.size && graph.skip[pos]) + ++pos; + return pos; +} + +bool single_path(const SpellingGraph &graph, std::vector &path) +{ + path.clear(); + if (graph.empty()) + return false; + // 从每个位置走到末尾有几条路,数到 2 就够了。 + const std::size_t n = graph.size; + std::vector ways(n + 1, 0); + ways[n] = 1; + for (std::size_t pos = n; pos-- > 0;) + { + int count = graph.skip[pos] ? ways[pos + 1] : 0; + for (const auto &spelling : graph.edges[pos]) + count += ways[spelling.end]; + ways[pos] = (std::min)(count, 2); + } + if (ways[0] != 1) + return false; + for (std::size_t pos = 0; pos < n;) + { + if (graph.skip[pos]) + { + ++pos; + continue; + } + const auto next = std::find_if(graph.edges[pos].begin(), graph.edges[pos].end(), + [&](const SyllableSpelling &spelling) { return ways[spelling.end] > 0; }); + if (next == graph.edges[pos].end()) + return false; + path.push_back(*next); + pos = next->end; + } + return true; +} + +} // namespace direct_helpcode diff --git a/engine/direct_helpcode/spelling_graph.h b/engine/direct_helpcode/spelling_graph.h new file mode 100644 index 00000000..5c064d3e --- /dev/null +++ b/engine/direct_helpcode/spelling_graph.h @@ -0,0 +1,93 @@ +#pragma once + +#include "../shuangpin/shuangpin_profile.h" + +#include +#include +#include +#include + +// 双拼直接辅助码(万象「直接辅助」)的拼写图。 +// +// 万象把每个字的辅码写进词库音节(shí;dk),再用拼写运算派生出可敲的形式,librime 在按键位置上 +// 把所有能匹配的拼写连成图、交给词格整句解码。这里在不改词库的前提下做同一件事:每个位置上 +// 一个音节可以写成 +// +// ui 双拼两键 Plain +// uia 双拼 + 第一位辅码 Aux1 +// uiab/ 双拼 + 两位辅码 + / Aux2Slash +// uiab 双拼 + 两位辅码(不带 /) Aux2Abbrev 缩写,见下 +// u 单个声母键 Initial 缩写,见下 +// +// 辅码那一段只有在「这个音节下真有字的辅码以它开头」时才成立(AuxPredicate),这对应万象词库里 +// 只为真实存在的 拼音;辅码 派生拼写。辅码不分大小写,和万象的大写变体一样。 +// +// 缩写(Aux2Abbrev、Initial)对应 librime 的 kAbbreviation:只要存在一条全由非缩写拼写组成、 +// 走到输入末尾的路径,所有缩写边就被剪掉(syllabifier.cc 的 stale edge 清理)。所以 uiab 单独 +// 敲时能当一个带两位辅码的字,放进句子里就会让位给 ui'ab 这类正常切分,要在句中用四码必须 +// 补 /。这正是万象的行为,不是这里的简化。 +namespace direct_helpcode +{ + +enum class SpellingKind +{ + Plain, + Aux1, + Aux2Slash, + Aux2Abbrev, + Initial, +}; + +struct SyllableSpelling +{ + // 在输入串里的范围 [begin, end),含辅码与 /。 + std::size_t begin = 0; + std::size_t end = 0; + // 小写的双拼码:两键,Initial 是一键。 + std::string code; + // 全拼音节;Initial 是声母。 + std::string quanpin; + // 小写辅码,0 表示没有。 + char first = 0; + char second = 0; + SpellingKind kind = SpellingKind::Plain; + + bool is_abbreviation() const + { + return kind == SpellingKind::Aux2Abbrev || kind == SpellingKind::Initial; + } + bool has_aux() const + { + return first != 0; + } +}; + +// 全拼音节 quanpin 下有没有字的辅码以 first(second 非 0 时为 first+second)开头。 +using AuxPredicate = std::function; + +struct SpellingGraph +{ + std::size_t size = 0; + // edges[pos]:从 pos 开始的拼写。只保留落在某条从 0 到 size 的完整路径上的边。 + std::vector> edges; + // skip[pos]:pos 上是分隔符,不出字,直接走到 pos + 1。手动分隔符 ' 总是;/ 与 ; 只在 + // 没有它们就切不通时才当分隔符兜底。 + std::vector skip; + + bool empty() const + { + return edges.empty(); + } +}; + +// 走不通时返回空图。 +SpellingGraph build_spelling_graph(const std::string &typed, const ShuangpinProfile &profile, + const AuxPredicate &has_aux); + +// 下一个出字的位置:跳过 pos 起连续的分隔符。 +std::size_t next_spelling_position(const SpellingGraph &graph, std::size_t pos); + +// 图里只有一条完整切分时返回它:没有可比的切法,不必整句解码。有零条或多条时返回 false。 +bool single_path(const SpellingGraph &graph, std::vector &path); + +} // namespace direct_helpcode diff --git a/engine/providers/pinyin_candidate_provider.cpp b/engine/providers/pinyin_candidate_provider.cpp index 24fa19e2..5e2b5631 100644 --- a/engine/providers/pinyin_candidate_provider.cpp +++ b/engine/providers/pinyin_candidate_provider.cpp @@ -38,6 +38,11 @@ std::vector PinyinCandidateProvider::query(const QueryRequest &request return candidates; } +bool PinyinCandidateProvider::resolve_direct_helpcode(QueryRequest &request) +{ + return request.scheme == SchemeType::Shuangpin && shuangpin_engine_.resolve_direct_helpcode(request); +} + bool PinyinCandidateProvider::expand_initial_candidates(const QueryRequest &request, std::vector &candidates) { bool expanded = false; diff --git a/engine/providers/pinyin_candidate_provider.h b/engine/providers/pinyin_candidate_provider.h index 2d1e4128..b90ac516 100644 --- a/engine/providers/pinyin_candidate_provider.h +++ b/engine/providers/pinyin_candidate_provider.h @@ -13,6 +13,8 @@ class PinyinCandidateProvider : public ICandidateProvider std::optional find_candidate(SchemeType scheme, const std::string &key, const std::string &value) override; bool expand_initial_candidates(const QueryRequest &request, std::vector &candidates); + // 双拼直接辅助码,见 ShuangpinEngine::resolve_direct_helpcode。 + bool resolve_direct_helpcode(QueryRequest &request); void reset_cache() override; void reset_sentence_cache(); int create_word(SchemeType scheme, std::string pinyin, std::string word) override; diff --git a/engine/providers/provider_registry.h b/engine/providers/provider_registry.h index 00d3f6bb..37b14444 100644 --- a/engine/providers/provider_registry.h +++ b/engine/providers/provider_registry.h @@ -14,6 +14,11 @@ class ProviderRegistry ICandidateProvider &resolve(SchemeType scheme_type); std::optional find_candidate(SchemeType scheme_type, const std::string &key, const std::string &value); bool expand_initial_candidates(const QueryRequest &request, std::vector &candidates); + // 双拼直接辅助码只出在拼音 provider 上,见 PinyinCandidateProvider::resolve_direct_helpcode。 + bool resolve_direct_helpcode(QueryRequest &request) + { + return pinyin_provider_.resolve_direct_helpcode(request); + } void reset_cache(SchemeType scheme_type); // 整句(词格 + 神经重排)只出在拼音 provider 上,五笔混输查的也是它。 void reset_sentence_cache() diff --git a/engine/quanpin/word_lattice.cpp b/engine/quanpin/word_lattice.cpp index 8c865840..9c1fea04 100644 --- a/engine/quanpin/word_lattice.cpp +++ b/engine/quanpin/word_lattice.cpp @@ -399,7 +399,8 @@ void merge_lattice_candidates(std::vector &candidates, const Segments if (!has_only_complete_pinyin_segments(syllables)) return; - auto paths = decode_word_lattice(syllables, lookup, options); + auto paths = + options.precomputed_paths.empty() ? decode_word_lattice(syllables, lookup, options) : options.precomputed_paths; if (paths.empty()) return; diff --git a/engine/quanpin/word_lattice.h b/engine/quanpin/word_lattice.h index e0a5f080..4517684d 100644 --- a/engine/quanpin/word_lattice.h +++ b/engine/quanpin/word_lattice.h @@ -142,6 +142,9 @@ struct WordLatticeOptions LatticeCollocationScorer collocation_scorer; // 搭配项的线性权重;0 = 关闭(scorer 非空也不生效)。 double collocation_weight = 0.0; + // 调用方已经按同一套打分解出来的 n-best(双拼直接辅助码的解析器选切分时顺带解出了最优整句)。 + // 非空时 merge_lattice_candidates 直接用它,不再重解一遍,其余合并逻辑不变。全拼从不设置。 + std::vector precomputed_paths; }; // Optional second opinion on the decoded n-best, applied before the paths become candidates. diff --git a/engine/schemes/shuangpin_scheme.cpp b/engine/schemes/shuangpin_scheme.cpp index 6f1089c5..be126653 100644 --- a/engine/schemes/shuangpin_scheme.cpp +++ b/engine/schemes/shuangpin_scheme.cpp @@ -77,7 +77,17 @@ void ShuangpinScheme::handle_key(ImeKeyCode vk, ImeModifierMask modifiers_down, return; } - const bool microsoft_ing_key = is_microsoft_ing_key(vk, wch, raw_input_, profile_); + if (direct_helpcode_ && wch == u'/') + { + key_strokes_.push_back(KeyStroke{vk, modifiers_down, wch}); + raw_input_.push_back('/'); + return; + } + + const bool microsoft_ing_key = direct_helpcode_ ? ShuangpinProfileUsesSemicolonFinal(profile_) && wch == u';' && + !raw_input_.empty() && + std::isalpha(static_cast(raw_input_.back())) + : is_microsoft_ing_key(vk, wch, raw_input_, profile_); if (!is_alpha_vk(vk) && !microsoft_ing_key) { return; diff --git a/engine/schemes/shuangpin_scheme.h b/engine/schemes/shuangpin_scheme.h index 957f7ccc..e3ce9207 100644 --- a/engine/schemes/shuangpin_scheme.h +++ b/engine/schemes/shuangpin_scheme.h @@ -15,9 +15,15 @@ class ShuangpinScheme : public IInputScheme std::string get_preedit() const override; SchemeType type() const override; void set_raw_input(const std::string &raw_input, const std::string &raw_input_with_cases) override; + // 直接辅助码开着时收 /(四码后的终止键),; 韵母也不再要求所在一节是奇数键:辅码会打乱奇偶。 + void set_direct_helpcode(bool enabled) + { + direct_helpcode_ = enabled; + } private: const ShuangpinProfile profile_; + bool direct_helpcode_ = false; std::string raw_input_; std::vector key_strokes_; }; diff --git a/engine/shuangpin/engine.cpp b/engine/shuangpin/engine.cpp index e7e15924..48da2e20 100644 --- a/engine/shuangpin/engine.cpp +++ b/engine/shuangpin/engine.cpp @@ -124,11 +124,24 @@ std::vector ShuangpinEngine::query(const QueryRequest &request) // 句中辅助码约束也随请求下发;开关关着时不筛(串里本就敲不进反引号,这里只是兜底)。 dictionary_.set_syllable_helpcodes(request.enable_mid_sentence_helpcode ? request.syllable_helpcodes : SyllableHelpcodes{}); + // 直接辅助码开着时词格的跨度查询走与解析器共用的缓存,见 ShuangpinDictionary::lattice_lookup。 + dictionary_.set_direct_span_cache_enabled(request.enable_direct_helpcode); auto candidates = query_unfiltered(request); dictionary_.filter_by_syllable_helpcodes(candidates); return candidates; } +bool ShuangpinEngine::resolve_direct_helpcode(QueryRequest &request) +{ + if (!request.valid) + { + return false; + } + // 解码要用的搭配模型跟着整句选项走,先下发,和 query 的顺序一致。 + dictionary_.set_sentence_association(request.sentence_association); + return dictionary_.resolve_direct_helpcode(request); +} + std::vector ShuangpinEngine::query_unfiltered(const QueryRequest &request) { const std::string &raw_input = request.raw_input; diff --git a/engine/shuangpin/engine.h b/engine/shuangpin/engine.h index 9867d327..5b3b0ad2 100644 --- a/engine/shuangpin/engine.h +++ b/engine/shuangpin/engine.h @@ -13,6 +13,8 @@ class ShuangpinEngine explicit ShuangpinEngine(const ShuangpinProfile &profile = GetXiaoheShuangpinProfile(), metasequoia::RuntimePaths paths = metasequoia::RuntimePaths::legacy()); std::vector query(const QueryRequest &request); + // 直接辅助码:在 query 之前把请求改写成句中辅助码的形状,见 ShuangpinDictionary::resolve_direct_helpcode。 + bool resolve_direct_helpcode(QueryRequest &request); bool expand_initial_candidates(const QueryRequest &request, std::vector &candidates); std::optional find_candidate(const std::string &key, const std::string &value); int create_word(std::string pinyin, std::string word); diff --git a/engine/shuangpin/shuangpin_dictionary.cpp b/engine/shuangpin/shuangpin_dictionary.cpp index bebe6c60..f9106309 100644 --- a/engine/shuangpin/shuangpin_dictionary.cpp +++ b/engine/shuangpin/shuangpin_dictionary.cpp @@ -1,4 +1,6 @@ #include "shuangpin_dictionary.h" +#include "../core/query_request.h" +#include "../direct_helpcode/direct_resolver.h" #include "../user_dictionary/user_dictionary_journal.h" #include "../common/helpcode_utils.h" #include "../quanpin/quanpin_query.h" @@ -271,18 +273,22 @@ vector ShuangpinDictionary::generateSeries( // return accepts_syllable_char(helpcode, hanzi); }}); } + // 直接辅助码的解析器选切分时已经按同一套打分解出了这串输入的最优整句:只要一条首选(没有重排器要 + // n-best),就直接用它,不再把同一张词格解第二遍。 + if (lattice_options.nbest == 1 && direct_sentence_ && direct_sentence_->first == effective_cache_key) + { + lattice_options.precomputed_paths = {direct_sentence_->second}; + } if (!lattice_options.char_constraints.empty()) { constexpr int kConstrainedSpanLimit = 4096; - lattice_options.constrained_lookup = - quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, kConstrainedSpanLimit); + lattice_options.constrained_lookup = lattice_lookup(kConstrainedSpanLimit); } if (sentence_association_.word_lattice || !neural_rerankers.empty()) { - quanpin::merge_lattice_candidates( - candidate_list, quanpin_syllables, - quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, lattice_options.span_limit), - pinyin_sequence, lattice_options, neural_rerankers); + quanpin::merge_lattice_candidates(candidate_list, quanpin_syllables, + lattice_lookup(lattice_options.span_limit), pinyin_sequence, + lattice_options, neural_rerankers); } /* 缓存起来 */ @@ -1174,6 +1180,114 @@ void ShuangpinDictionary::reset_cache() _cached_buffer_sgl_reversed.clear(); _cached_buffer_dbl.clear(); _cached_buffer_series.clear(); + direct_span_cache_.clear(); + if (direct_resolver_) + { + direct_resolver_->reset_cache(); + } +} + +quanpin::WordLatticeLookup ShuangpinDictionary::lattice_lookup(int limit) +{ + auto lookup = quanpin::make_lattice_db_lookup(quanpin_db_, quanpin_statement_cache_, limit); + if (!direct_span_cache_enabled_) + { + return lookup; + } + return [this, lookup = std::move(lookup), limit](const quanpin::Segments &span) { + // 超过上限整体清掉,免得一次长时间输入把内存撑大。 + constexpr std::size_t kDirectSpanCacheLimit = 8192; + std::string key = quanpin::join_segments(span); + key.push_back('#'); + key += std::to_string(limit); + auto found = direct_span_cache_.find(key); + if (found == direct_span_cache_.end()) + { + if (direct_span_cache_.size() >= kDirectSpanCacheLimit) + { + direct_span_cache_.clear(); + } + found = direct_span_cache_.emplace(std::move(key), lookup(span)).first; + } + return found->second; + }; +} + +bool ShuangpinDictionary::resolve_direct_helpcode(QueryRequest &request) +{ + reset_cache_if_database_changed(); + if (!direct_resolver_) + { + direct_resolver_ = std::make_unique(profile_); + } + // 与 generateSeries 的词格同一套查询:普通跨度在 SQL 里截到 32 行,带辅码约束的跨度不截, + // 筛完约束再截(生僻字才筛得出来)。 + constexpr int kSpanLimit = 32; + constexpr int kConstrainedSpanLimit = 4096; + // 解析器只在直接辅助码开着时跑,查到的行进共用缓存,随后的 generateSeries 直接复用。 + direct_span_cache_enabled_ = true; + auto lookup = lattice_lookup(kSpanLimit); + auto constrained_lookup = lattice_lookup(kConstrainedSpanLimit); + direct_helpcode::ResolveContext context; + context.lookup = [lookup, constrained_lookup](const quanpin::Segments &span, bool constrained) { + return constrained ? constrained_lookup(span) : lookup(span); + }; + context.single_char_rows = [this]() { return query_single_char_rows(); }; + context.keymap = helpcodes_ ? helpcodes_.get() : &HelpcodeUtils::helpcode_keymap(); + context.options.language_model = language_model_; + if (collocation_db_ != nullptr && collocation_db_->valid() && sentence_association_.collocation_weight != 0.0) + { + context.options.collocation_scorer = [db = collocation_db_](std::string_view tail, std::string_view word, + bool is_rear) { + return db->query(std::string(tail), std::string(word), is_rear, gram::GrammarConfig{}); + }; + context.options.collocation_weight = sentence_association_.collocation_weight; + } + const bool rewritten = direct_resolver_->resolve(request, context); + // 记下解出的整句,键与 generateSeries 的缓存键同义(请求最终的 raw_input + 约束签名),见那边的词格块。 + direct_sentence_.reset(); + if (const auto *sentence = direct_resolver_->resolved_sentence()) + { + quanpin::LatticePath path; + path.sentence = sentence->sentence; + path.key = sentence->key; + path.log_prob = sentence->log_prob; + path.words = sentence->words; + direct_sentence_.emplace(request.raw_input + syllable_helpcodes_signature(request.syllable_helpcodes), + std::move(path)); + } + return rewritten; +} + +std::vector> ShuangpinDictionary::query_single_char_rows() +{ + // 单字都在 tbl_1_<首字母> 里(表名规则见 contracts/dictionary/format.json),逐表全扫一遍。 + std::vector> rows; + if (quanpin_db_ == nullptr) + { + return rows; + } + for (char letter = 'a'; letter <= 'z'; ++letter) + { + const std::string sql = "SELECT key, value FROM " + quanpin::build_table_name({std::string(1, letter)}); + sqlite3_stmt *statement = nullptr; + if (sqlite3_prepare_v2(quanpin_db_, sql.c_str(), -1, &statement, nullptr) != SQLITE_OK) + { + sqlite3_finalize(statement); + continue; + } + while (sqlite3_step(statement) == SQLITE_ROW) + { + const auto *key = reinterpret_cast(sqlite3_column_text(statement, 0)); + const auto *value = reinterpret_cast(sqlite3_column_text(statement, 1)); + if (key != nullptr && value != nullptr) + { + rows.emplace_back(key, value); + } + } + sqlite3_finalize(statement); + } + return rows; } void ShuangpinDictionary::reset_sentence_cache() diff --git a/engine/shuangpin/shuangpin_dictionary.h b/engine/shuangpin/shuangpin_dictionary.h index 43b4f9a3..311f3593 100644 --- a/engine/shuangpin/shuangpin_dictionary.h +++ b/engine/shuangpin/shuangpin_dictionary.h @@ -25,6 +25,12 @@ #include #include +struct QueryRequest; +namespace direct_helpcode +{ +class Resolver; +} + class ShuangpinDictionary { public: @@ -82,6 +88,14 @@ class ShuangpinDictionary // 候选覆盖到受约束的音节时,那个位置上的字必须满足约束,不满足的去掉。 void filter_by_syllable_helpcodes(std::vector &candidates) const; + // 直接辅助码:整句解码选出切分,把请求改写成句中辅助码的形状,见 direct_helpcode/direct_resolver.h。 + bool resolve_direct_helpcode(QueryRequest &request); + // 直接辅助码开着时,词格的跨度查询与解析器共用一份缓存(见 lattice_lookup);关着时照旧直查。 + void set_direct_span_cache_enabled(bool enabled) + { + direct_span_cache_enabled_ = enabled; + } + explicit ShuangpinDictionary(const ShuangpinProfile &profile = GetXiaoheShuangpinProfile(), metasequoia::RuntimePaths paths = metasequoia::RuntimePaths::legacy()); ~ShuangpinDictionary(); @@ -114,6 +128,18 @@ class ShuangpinDictionary SyllableHelpcodes syllable_helpcodes_; std::string syllable_helpcodes_signature_; std::unordered_map quanpin_statement_cache_; + // 直接辅助码开着时才建。 + std::unique_ptr direct_resolver_; + // 解析器上一次解出的最优整句及它对应的 generateSeries 缓存键,词格合并时直接用它而不重解。 + std::optional> direct_sentence_; + // 词库里全部单字行(全拼读音、单字),直接辅助码建辅码索引用。 + std::vector> query_single_char_rows(); + // 词格的跨度查询(键与跨度完全相等,至多 limit 行)。直接辅助码开着时套一层按(跨度, limit)的缓存: + // 解析器选切分时已经查过同样的跨度,词典层整句解码不必再打一遍 SQL。结果与直查逐行相同;词库一变 + // (reset_cache)缓存随之清空。 + quanpin::WordLatticeLookup lattice_lookup(int limit); + bool direct_span_cache_enabled_ = false; + std::unordered_map> direct_span_cache_; void reset_cache_if_database_changed(); // 这个字能不能落在 helpcode 约束的音节上。 bool accepts_syllable_char(const SyllableHelpcode &helpcode, const std::string &hanzi) const; diff --git a/engine/shuangpin/shuangpin_query.cpp b/engine/shuangpin/shuangpin_query.cpp index 5c240957..0811f46f 100644 --- a/engine/shuangpin/shuangpin_query.cpp +++ b/engine/shuangpin/shuangpin_query.cpp @@ -384,6 +384,16 @@ std::string decorate_mid_sentence_segmentation(const std::string &segmentation, { return segmentation; } + return decorate_segmentation(segmentation, parse_mid_sentence_helpcodes(raw_input_with_cases, profile).decorations); +} + +std::string decorate_segmentation(const std::string &segmentation, + const std::vector> &decorations) +{ + if (decorations.empty() || segmentation.empty()) + { + return segmentation; + } std::vector parts; std::size_t start = 0; while (true) @@ -395,7 +405,7 @@ std::string decorate_mid_sentence_segmentation(const std::string &segmentation, break; start = separator + 1; } - for (const auto &[syllable, text] : parse_mid_sentence_helpcodes(raw_input_with_cases, profile).decorations) + for (const auto &[syllable, text] : decorations) { if (syllable < parts.size()) parts[syllable] += text; diff --git a/engine/shuangpin/shuangpin_query.h b/engine/shuangpin/shuangpin_query.h index 4aa41ed0..c9c8424b 100644 --- a/engine/shuangpin/shuangpin_query.h +++ b/engine/shuangpin/shuangpin_query.h @@ -62,6 +62,9 @@ MidSentenceHelpcodeInput parse_mid_sentence_helpcodes(const std::string &raw_inp // 由 parse_mid_sentence_helpcodes(...).input 切出来的(音节序号才对得上)。 std::string decorate_mid_sentence_segmentation(const std::string &segmentation, const std::string &raw_input_with_cases, const ShuangpinProfile &profile = GetXiaoheShuangpinProfile()); +// 同上,段文本已经在手:直接辅助码由解析器给出(原串里没有反引号可解析)。 +std::string decorate_segmentation(const std::string &segmentation, + const std::vector> &decorations); // 输入串末尾能否接一个反引号,即 FanyImeMidSentenceHelpcode::AcceptsMarker。 bool accepts_mid_sentence_helpcode_marker(const std::string &raw_input); // 光标停在 raw_input[caret] 时能否插入一个反引号,即 FanyImeMidSentenceHelpcode::AcceptsMarkerAt。 diff --git a/engine/tests/src/bench_input_session.cpp b/engine/tests/src/bench_input_session.cpp index 2b95909d..9781dec1 100644 --- a/engine/tests/src/bench_input_session.cpp +++ b/engine/tests/src/bench_input_session.cpp @@ -102,5 +102,50 @@ int main(int argc, char **argv) percentile(per_key, 0.9), percentile(per_key, 1.0), percentile(per_input, 0.5)); } session.reset_state(); + + // 双拼直接辅助码的额外代价:同一批小鹤双拼按键(含夹在句中的辅码字母),开关关/开各量一遍, + // 整句选项固定为 lattice+google。 + const std::vector shuangpin_inputs = { + "uiui", "uiauiq", "nihcuiui", "uiauiquhlmmyzuiauib", "womfyiqiqu", "jintmtmqibucoaeje", + }; + SentenceAssociationOptions lattice_google; + lattice_google.word_lattice = true; + lattice_google.google = true; + std::printf("\n%-18s %10s %10s %10s %10s (shuangpin, ms per key)\n", "direct helpcode", "median", "p90", "max", + "per-input"); + for (const bool direct : {false, true}) + { + metasequoia::InputSession shuangpin(SchemeType::Shuangpin); + shuangpin.set_sentence_association(lattice_google); + shuangpin.set_shuangpin_helpcode_enabled(!direct); + shuangpin.set_direct_helpcode_enabled(direct); + shuangpin.set_helpcode_schema("ziranma"); + std::vector per_key; + std::vector per_input; + for (int round = -1; round < repeats; ++round) + { + for (const std::string &input : shuangpin_inputs) + { + shuangpin.reset_state(); + shuangpin.reset_cache(); + double total = 0.0; + for (const char character : input) + { + const auto start = std::chrono::steady_clock::now(); + shuangpin.handle_character(character); + const double ms = + std::chrono::duration(std::chrono::steady_clock::now() - start).count(); + total += ms; + if (round >= 0) + per_key.push_back(ms); + } + if (round >= 0) + per_input.push_back(total); + } + } + std::printf("%-18s %10.2f %10.2f %10.2f %10.2f\n", direct ? "on" : "off", percentile(per_key, 0.5), + percentile(per_key, 0.9), percentile(per_key, 1.0), percentile(per_input, 0.5)); + shuangpin.reset_state(); + } return 0; } diff --git a/engine/tests/src/test_direct_helpcode.cpp b/engine/tests/src/test_direct_helpcode.cpp new file mode 100644 index 00000000..1d2811ca --- /dev/null +++ b/engine/tests/src/test_direct_helpcode.cpp @@ -0,0 +1,384 @@ +#include "../../core/input_session.h" +#include "../../core/data_path.h" +#include "../../direct_helpcode/spelling_graph.h" +#include "../../shuangpin/shuangpin_profile.h" +#include "test_directory_cleanup.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +// 双拼直接辅助码(万象式)。夹具用小鹤双拼:shi = ui。辅助码表(lantian 槽位): +// 石=ab 狮=qa 事=yb 实=tb 时=rc 世=ss +// 词库:事实 500、时事 300、石狮 100(都读 shi'shi),单字 shi 若干。 +namespace +{ +using direct_helpcode::SpellingKind; + +void require(bool condition, const char *message) +{ + if (!condition) + { + throw std::runtime_error(message); + } +} + +class Database +{ + public: + explicit Database(const std::filesystem::path &path) + { + if (sqlite3_open(metasequoia::path_to_utf8(path).c_str(), &database_) != SQLITE_OK) + { + throw std::runtime_error("Failed to create the direct-helpcode test dictionary."); + } + } + ~Database() + { + sqlite3_close(database_); + } + void execute(const char *sql) + { + char *error = nullptr; + if (sqlite3_exec(database_, sql, nullptr, nullptr, &error) != SQLITE_OK) + { + const std::string message = error == nullptr ? "SQLite operation failed." : error; + sqlite3_free(error); + throw std::runtime_error(message); + } + } + + private: + sqlite3 *database_ = nullptr; +}; + +void write_file(const std::filesystem::path &path, const std::string &contents) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream stream(path); + stream << contents; + if (!stream) + { + throw std::runtime_error("Failed to prepare a direct-helpcode fixture."); + } +} + +void set_data_directory(const std::filesystem::path &directory) +{ +#ifdef _WIN32 + if (_wputenv_s(L"METASEQUOIA_IME_DATA_DIR", directory.c_str()) != 0) +#else + if (setenv("METASEQUOIA_IME_DATA_DIR", metasequoia::path_to_utf8(directory).c_str(), 1) != 0) +#endif + { + throw std::runtime_error("Failed to set the data directory override."); + } +} + +void type(metasequoia::InputSession &session, const std::string &text) +{ + for (const char character : text) + { + if (!session.handle_character(character).handled) + { + throw std::runtime_error(std::string("A key was not handled: ") + character); + } + } +} + +std::size_t candidate_index(const metasequoia::InputSession &session, const std::string &word) +{ + const auto &items = session.candidates(); + for (std::size_t i = 0; i < items.size(); ++i) + { + if (items[i].word == word) + return i; + } + return items.size(); +} + +bool has_word(const metasequoia::InputSession &session, const std::string &word) +{ + return candidate_index(session, word) < session.candidates().size(); +} + +std::string first_word(const metasequoia::InputSession &session) +{ + return session.candidates().empty() ? std::string{} : session.candidates().front().word; +} + +const direct_helpcode::SyllableSpelling *find_edge(const direct_helpcode::SpellingGraph &graph, std::size_t pos, + SpellingKind kind) +{ + if (pos >= graph.edges.size()) + return nullptr; + for (const auto &spelling : graph.edges[pos]) + { + if (spelling.kind == kind) + return &spelling; + } + return nullptr; +} + +// 纯拼写图:不碰词库,只验证万象的拼写规则和 librime 的缩写剪枝。 +void run_spelling_graph_tests() +{ + const ShuangpinProfile &profile = GetXiaoheShuangpinProfile(); + // shi 下有辅码以 a、q 开头的字,以及完整的 ab、ui。 + const direct_helpcode::AuxPredicate has_aux = [](const std::string &quanpin, char first, char second) { + if (quanpin != "shi") + return false; + if (second == 0) + return first == 'a' || first == 'q' || first == 'u'; + return (first == 'a' && second == 'b') || (first == 'u' && second == 'i'); + }; + + { + const auto graph = direct_helpcode::build_spelling_graph("uiauiq", profile, has_aux); + require(!graph.empty(), "uiauiq produced no spelling graph."); + const auto *first = find_edge(graph, 0, SpellingKind::Aux1); + const auto *second = find_edge(graph, 3, SpellingKind::Aux1); + require(first && first->end == 3 && first->first == 'a' && first->quanpin == "shi", + "uia was not recognised as shi with first code a."); + require(second && second->end == 6 && second->first == 'q', "uiq was not recognised as shi with code q."); + require(!find_edge(graph, 0, SpellingKind::Plain), + "ui at the start survived although it cannot reach the end of uiauiq."); + } + + { + // 整串就是四码:没有普通切法能走完,缩写边保留。 + const auto graph = direct_helpcode::build_spelling_graph("uiab", profile, has_aux); + const auto *edge = find_edge(graph, 0, SpellingKind::Aux2Abbrev); + require(edge && edge->end == 4 && edge->first == 'a' && edge->second == 'b', + "A whole-input four-key code was not kept as an abbreviation."); + } + + { + // ui|ui 是一条完整的普通切法:四码 uiui(shi + 辅码 ui)是缩写,被剪掉;三码 uiu 之后只剩一个 + // 声母缩写,也走不到底。 + const auto graph = direct_helpcode::build_spelling_graph("uiui", profile, has_aux); + require(find_edge(graph, 0, SpellingKind::Plain) && !find_edge(graph, 0, SpellingKind::Aux2Abbrev) && + !find_edge(graph, 0, SpellingKind::Aux1), + "A four-key code survived next to a complete normal segmentation."); + } + + { + // 补上 / 之后四码是正常拼写,成为唯一走得通的切法。 + const auto graph = direct_helpcode::build_spelling_graph("uiui/", profile, has_aux); + const auto *edge = find_edge(graph, 0, SpellingKind::Aux2Slash); + require(edge && edge->end == 5 && edge->first == 'u' && edge->second == 'i', + "The slash did not turn the four-key code into a normal spelling."); + require(!find_edge(graph, 0, SpellingKind::Plain), "ui|ui survived although the slash cannot be consumed."); + } + + { + // 凑不成四码的 / 退回成分隔符,整串不作废。 + const auto graph = direct_helpcode::build_spelling_graph("ui/", profile, has_aux); + require(!graph.empty() && graph.skip[2] && find_edge(graph, 0, SpellingKind::Plain), + "An unusable slash invalidated the whole input."); + } + + { + // 打到一半剩一个声母:没有完整切法时声母缩写保留。 + const auto graph = direct_helpcode::build_spelling_graph("uix", profile, has_aux); + require(find_edge(graph, 0, SpellingKind::Plain) && find_edge(graph, 2, SpellingKind::Initial), + "A trailing initial was not kept when nothing else reaches the end."); + } + + { + // 只有一条完整切分时不必整句解码:uiauiq 只能切成 uia|uiq。 + std::vector path; + const auto graph = direct_helpcode::build_spelling_graph("uiauiq", profile, has_aux); + require(direct_helpcode::single_path(graph, path) && path.size() == 2 && path[0].end == 3 && path[1].end == 6, + "uiauiq was not recognised as a single segmentation."); + // uiu 能切成 ui|u(声母缩写)或 uiu(三码),剪枝后只剩三码,同样唯一。 + const auto three = direct_helpcode::build_spelling_graph("uiu", profile, has_aux); + require(direct_helpcode::single_path(three, path) && path.size() == 1 && path[0].kind == SpellingKind::Aux1, + "uiu did not reduce to its three-key spelling."); + // 走不通的图没有唯一切分。 + require(!direct_helpcode::single_path(direct_helpcode::SpellingGraph{}, path) && path.empty(), + "An empty graph was reported as a single segmentation."); + } + + { + // 没有辅码可用时(码表里没有)三码不成立。 + const direct_helpcode::AuxPredicate none = [](const std::string &, char, char) { return false; }; + const auto graph = direct_helpcode::build_spelling_graph("uia", profile, none); + require(!find_edge(graph, 0, SpellingKind::Aux1), "A three-key code was accepted without any matching char."); + } +} + +void prepare_fixture(const std::filesystem::path &directory) +{ + write_file(directory / "helpcodes" / "helpcode.txt", "石=ab\n狮=qa\n事=yb\n实=tb\n时=rc\n世=ss\n"); + Database database(directory / "msime.db"); + database.execute("BEGIN;" + "CREATE TABLE tbl_1_s(key TEXT, jp TEXT, value TEXT, weight INTEGER);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '是', 900);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '时', 800);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '事', 700);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '实', 600);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '世', 500);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '石', 400);" + "INSERT INTO tbl_1_s VALUES('shi', 's', '狮', 300);" + "CREATE TABLE tbl_2_s(key TEXT, jp TEXT, value TEXT, weight INTEGER);" + "INSERT INTO tbl_2_s VALUES('shi''shi', 'ss', '事实', 500);" + "INSERT INTO tbl_2_s VALUES('shi''shi', 'ss', '时事', 300);" + "INSERT INTO tbl_2_s VALUES('shi''shi', 'ss', '石狮', 100);" + "COMMIT;"); +} + +std::unique_ptr direct_session(bool enabled = true) +{ + auto session = std::make_unique(SchemeType::Shuangpin); + session->set_shuangpin_helpcode_enabled(false); + session->set_direct_helpcode_enabled(enabled); + require(session->set_helpcode_schema("lantian"), "The fixture helpcode table was not selected."); + return session; +} + +void run_session_tests() +{ + { + // 不带辅码的输入:与开关关着时完全一样。 + auto on = direct_session(true); + auto off = direct_session(false); + type(*on, "uiui"); + type(*off, "uiui"); + require(first_word(*on) == "事实" && first_word(*on) == first_word(*off) && + on->candidates().size() == off->candidates().size(), + "Plain shuangpin changed when direct helpcode was turned on."); + require(on->get_pinyin_sequence() == "uiui", "Plain shuangpin was rewritten by the resolver."); + } + + { + // 三码自动识别,不要引导键:uia = 石(ab),uiq = 狮(qa)。 + auto session = direct_session(); + type(*session, "uiauiq"); + require(first_word(*session) == "石狮", "uiauiq did not decode to 石狮."); + require(!has_word(*session, "事实") && !has_word(*session, "时事"), + "Candidates that violate the direct helpcodes were kept."); + require(session->preedit() == "uiauiq" && session->get_pinyin_sequence_with_cases() == "uiauiq", + "The typed direct helpcode letters were not kept as typed."); + require(session->get_pinyin_sequence() == "ui'ui", "The resolver did not strip the helpcode letters."); + require(session->get_pinyin_segmentation_with_cases() == "uia'uiq", + "The helpcode letters were not shown after their syllables."); + } + + { + // 单独三码:石。 + auto session = direct_session(); + type(*session, "uia"); + require(first_word(*session) == "石", "uia did not put 石 first."); + require(!has_word(*session, "是"), "A char without the typed helpcode survived uia."); + } + + { + // 大写辅码与小写等价(万象的大写变体)。 + auto session = direct_session(); + type(*session, "uiAuiQ"); + require(first_word(*session) == "石狮", "Uppercase direct helpcodes were not accepted."); + } + + { + // 整串四码:两位辅码定字。 + auto session = direct_session(); + type(*session, "uiab"); + require(first_word(*session) == "石", "A whole-input four-key code did not select 石."); + } + + { + // / 的门槛:前面不是「两键 + 两码」时不收。 + auto session = direct_session(); + type(*session, "uia"); + require(!session->handle_character('/').handled && session->preedit() == "uia", + "A slash was accepted after a three-key code."); + type(*session, "b"); + require(session->handle_character('/').handled && session->preedit() == "uiab/", + "A slash after a four-key code was not accepted."); + require(!session->handle_character('/').handled, "A second slash was accepted."); + // 四码 + / 之后接着打:/ 是终止键,后面的音节照常解码。 + type(*session, "ui"); + require(first_word(*session) == "石狮", "uiab/ui did not keep 石 on the first syllable."); + require(session->get_pinyin_segmentation_with_cases() == "uiab/'ui", + "The four-key code and its slash were not shown after their syllable."); + } + + { + // 开关关着:/ 不收,三码不当辅码。 + auto session = direct_session(false); + type(*session, "uiab"); + require(!session->handle_character('/').handled, "A slash was accepted with direct helpcode off."); + } + + { + // 选掉前一个字,后面的辅码随剩余部分留下来。 + auto session = direct_session(); + type(*session, "uiauiq"); + const std::size_t index = candidate_index(*session, "石"); + require(index < session->candidates().size(), "The single char 石 was not offered for uiauiq."); + const auto committed = session->select_candidate(index); + require(committed.commit == "石" && session->preedit() == "uiq", + "Selecting the first char did not leave the remaining syllable with its helpcode."); + require(first_word(*session) == "狮", "The carried helpcode no longer filtered the remaining syllable."); + } + + { + // 退格删掉辅码字母后回到普通双拼。 + auto session = direct_session(); + type(*session, "uia"); + require(session->handle_command(metasequoia::Command::Backspace).handled && session->preedit() == "ui", + "Backspace did not remove the helpcode letter."); + // 前面的用例选过「石」,调频已经把它提上来了,所以和关着开关的会话比,而不是写死首选。 + auto off = direct_session(false); + type(*off, "ui"); + require(session->candidates().size() == off->candidates().size() && first_word(*session) == first_word(*off), + "Removing the helpcode letter did not restore plain candidates."); + } + + { + // 直接辅助码开着时反引号不再是编码键。 + auto session = direct_session(); + session->set_mid_sentence_helpcode_enabled(true); + type(*session, "ui"); + require(!session->handle_character('`').handled, "A backquote was accepted while direct helpcode was on."); + } +} + +int run() +{ + run_spelling_graph_tests(); + + const auto unique_suffix = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); + const std::filesystem::path data_directory = + std::filesystem::temp_directory_path() / ("metasequoia-direct-helpcode-" + unique_suffix); + metasequoia::test::ScopedDataDirectoryCleanup cleanup(data_directory); + std::filesystem::create_directories(data_directory); + set_data_directory(data_directory); + prepare_fixture(data_directory); + run_session_tests(); + return 0; +} +} // namespace + +int main() +{ + try + { + return run(); + } + catch (const std::exception &error) + { + std::fprintf(stderr, "test_direct_helpcode failed: %s\n", error.what()); + return 1; + } +} diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index ea9fea3f..61c9a6a6 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -310,6 +310,10 @@ shuangpin_mid_sentence_helpcode = false # 只有在完整音节后才当触发键 shuangpin_mid_sentence_helpcode_backtick = true shuangpin_mid_sentence_helpcode_semicolon = false +# 双拼直接辅助码(万象式):不要引导键,音节后直接接辅码。三码(双拼 + 第一位辅码,如 uia)自动识别、 +# 整句断句;四码(双拼 + 两位辅码)单独敲时直接定字,句中要在后面补一个 /(如 uiab/)。开启后 +# 末尾辅助码与上面的句中辅助码(反引号/分号)都让位给它 +shuangpin_direct_helpcode = false # 全拼辅助码 quanpin_helpcode = true # 全拼辅助码方案,可选:"lantian", "ziranma", "shouyou2_0", "shouyouplus", "xiaohe", "jiajia", diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index 0fc401e4..9d79371b 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -309,6 +309,10 @@ shuangpin_mid_sentence_helpcode = false # 只有在完整音节后才当触发键 shuangpin_mid_sentence_helpcode_backtick = true shuangpin_mid_sentence_helpcode_semicolon = false +# 双拼直接辅助码(万象式):不要引导键,音节后直接接辅码。三码(双拼 + 第一位辅码,如 uia)自动识别、 +# 整句断句;四码(双拼 + 两位辅码)单独敲时直接定字,句中要在后面补一个 /(如 uiab/)。开启后 +# 末尾辅助码与上面的句中辅助码(反引号/分号)都让位给它 +shuangpin_direct_helpcode = false # 全拼辅助码 quanpin_helpcode = true # 全拼辅助码方案,可选:"lantian", "ziranma", "shouyou2_0", "shouyouplus", "xiaohe", "jiajia", diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index c31c0c62..fbedc03a 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -85,6 +85,7 @@ bool g_shuangpin_helpcode_enabled = true; bool g_shuangpin_mid_sentence_helpcode_enabled = false; bool g_shuangpin_mid_sentence_helpcode_backtick = true; bool g_shuangpin_mid_sentence_helpcode_semicolon = false; +bool g_shuangpin_direct_helpcode_enabled = false; bool g_quanpin_helpcode_enabled = true; std::string g_shuangpin_helpcode_schema = "lantian"; std::string g_quanpin_helpcode_schema = "lantian"; @@ -422,6 +423,7 @@ bool LoadImeConfig() tbl["helpcode"]["shuangpin_mid_sentence_helpcode_backtick"].value_or(true); g_shuangpin_mid_sentence_helpcode_semicolon = tbl["helpcode"]["shuangpin_mid_sentence_helpcode_semicolon"].value_or(false); + g_shuangpin_direct_helpcode_enabled = tbl["helpcode"]["shuangpin_direct_helpcode"].value_or(false); g_quanpin_helpcode_enabled = tbl["helpcode"]["quanpin_helpcode"].value_or(true); const std::string shuangpin_helpcode_schema = tbl["helpcode"]["shuangpin_helpcode_schema"].value_or(std::string("lantian")); diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index 920c933d..7bce44ac 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -283,6 +283,12 @@ bool IsConfiguredMidSentenceHelpcodeTrigger(wchar_t ch); std::wstring FormatMidSentenceHelpcodeWorkerPayload(); // MidSentenceHelpcodeSemicolonChanged 的载荷:同上,换成分号。 std::wstring FormatMidSentenceHelpcodeSemicolonWorkerPayload(); +// 双拼直接辅助码(helpcode.shuangpin_direct_helpcode,万象式,不要引导键),默认关闭。开着时句中辅助码的 +// 触发键一律不生效(IsConfiguredMidSentenceHelpcodeTrigger 返回 false),末尾单码/双码辅助也让位给它。 +bool GetConfiguredShuangpinDirectHelpcodeEnabled(); +bool SetConfiguredShuangpinDirectHelpcodeEnabled(bool enabled); +// DirectHelpcodeChanged 的载荷:"1" 表示开关开着且当前方案是双拼。 +std::wstring FormatDirectHelpcodeWorkerPayload(); const std::string &GetConfiguredShuangpinHelpcodeSchema(); bool SetConfiguredShuangpinHelpcodeSchema(const std::string &schema); bool GetConfiguredQuanpinHelpcodeEnabled(); diff --git a/server/src/config/ime_config_input.cpp b/server/src/config/ime_config_input.cpp index ecd3a3b0..834cde29 100644 --- a/server/src/config/ime_config_input.cpp +++ b/server/src/config/ime_config_input.cpp @@ -397,9 +397,31 @@ bool SetConfiguredShuangpinMidSentenceHelpcodeSemicolon(bool enabled) return true; } +bool GetConfiguredShuangpinDirectHelpcodeEnabled() +{ + return g_shuangpin_direct_helpcode_enabled; +} + +bool SetConfiguredShuangpinDirectHelpcodeEnabled(bool enabled) +{ + if (!WriteConfiguredValue("helpcode", "shuangpin_direct_helpcode", enabled ? "true" : "false")) + { + return false; + } + g_shuangpin_direct_helpcode_enabled = enabled; + return true; +} + +std::wstring FormatDirectHelpcodeWorkerPayload() +{ + return g_shuangpin_direct_helpcode_enabled && GetConfiguredActiveInputScheme() == SchemeType::Shuangpin ? L"1" + : L"0"; +} + bool IsConfiguredMidSentenceHelpcodeTrigger(wchar_t ch) { - if (!g_shuangpin_mid_sentence_helpcode_enabled) + // 直接辅助码开着时引擎不收反引号段(两套辅码会把同一个音节约束两遍),触发键跟着失效。 + if (!g_shuangpin_mid_sentence_helpcode_enabled || g_shuangpin_direct_helpcode_enabled) { return false; } diff --git a/server/src/config/ime_config_internal.h b/server/src/config/ime_config_internal.h index 29851a3b..dd15b7eb 100644 --- a/server/src/config/ime_config_internal.h +++ b/server/src/config/ime_config_internal.h @@ -138,6 +138,7 @@ extern bool g_shuangpin_helpcode_enabled; extern bool g_shuangpin_mid_sentence_helpcode_enabled; extern bool g_shuangpin_mid_sentence_helpcode_backtick; extern bool g_shuangpin_mid_sentence_helpcode_semicolon; +extern bool g_shuangpin_direct_helpcode_enabled; extern bool g_quanpin_helpcode_enabled; extern std::string g_shuangpin_helpcode_schema; extern std::string g_quanpin_helpcode_schema; diff --git a/server/src/ipc/event_listener_keys.cpp b/server/src/ipc/event_listener_keys.cpp index 64eb248f..511b5e45 100644 --- a/server/src/ipc/event_listener_keys.cpp +++ b/server/src/ipc/event_listener_keys.cpp @@ -9,6 +9,7 @@ #include "ipc/candidate_text_policy.h" #include "ipc/candidate_translation_policy.h" #include "ipc/input_key_policy.h" +#include "engine/contracts/direct_helpcode.h" #include "engine/contracts/ipc_negotiation.h" #include "defines/defines.h" #include "defines/globals.h" @@ -125,11 +126,32 @@ bool IsMicrosoftShuangpinIngKeyAt(UINT keycode, WCHAR wch, const std::string &ra return false; } + // 直接辅助码的辅码会打乱奇偶,改成只看前一个键是不是字母;TSF 按同一条规则吃键。 + if (GetConfiguredShuangpinDirectHelpcodeEnabled()) + { + return FanyImeDirectHelpcode::AcceptsSemicolonFinalAt(raw_input.data(), raw_input.size(), caret); + } const size_t separator = caret == 0 ? std::string::npos : raw_input.rfind('\'', caret - 1); const size_t chunk_start = separator == std::string::npos ? 0 : separator + 1; return (caret - chunk_start) % 2 == 1; } +// 双拼直接辅助码四码后的 /:光标前是「两键音节 + 两位辅码」的形状时它是编码键而不是标点。TSF 按同一条 +// 形状规则(FanyImeDirectHelpcode::AcceptsSlashAt)预判吃键,光标按 IsMidSentenceHelpcodeMarkerKey 同样的 +// 方式算。 +bool IsDirectHelpcodeSlashKey(UINT keycode, WCHAR wch, const std::string &raw_input) +{ + if (keycode != VK_OEM_2 || wch != L'/' || g_english_input_mode || g_inputSession == nullptr) + { + return false; + } + const auto &composition = GlobalIme::composition; + const size_t caret = composition.raw_input_with_cases != raw_input && composition.caret_position == 0 + ? raw_input.size() + : (std::min)(composition.caret_position, raw_input.size()); + return g_inputSession->accepts_direct_helpcode_slash(caret); +} + bool IsMicrosoftShuangpinIngKey(UINT keycode, WCHAR wch, const std::string &raw_input) { return IsMicrosoftShuangpinIngKeyAt(keycode, wch, raw_input, @@ -383,6 +405,10 @@ bool ApplyCompositionEditKey(UINT keycode, WCHAR wch, UINT modifiers_down, bool { input = '`'; } + else if (IsDirectHelpcodeSlashKey(keycode, wch, raw)) + { + input = '/'; + } else if (IsJapaneseLongVowelKey(keycode, wch)) { input = '-'; @@ -853,6 +879,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques IsMicrosoftShuangpinIngKey(Global::Keycode, Global::Wch, input_before_key); const bool is_mid_sentence_helpcode_marker = IsMidSentenceHelpcodeMarkerKey(Global::Keycode, Global::Wch, input_before_key); + const bool is_direct_helpcode_slash = IsDirectHelpcodeSlashKey(Global::Keycode, Global::Wch, input_before_key); // 日语模式下 '-' 是长音符输入键,既不翻页也不做词转字。 const bool is_japanese_long_vowel = IsJapaneseLongVowelKey(Global::Keycode, Global::Wch); const int word_character_direction = @@ -862,6 +889,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques const bool is_commit_with_highlighted_candidate_punctuation = word_character_direction != 0 || (!is_manual_pinyin_separator && !is_microsoft_shuangpin_ing_key && !is_mid_sentence_helpcode_marker && + !is_direct_helpcode_slash && IsCommitWithHighlightedCandidatePunctuationInCandidateMode(Global::Keycode, Global::Wch)); const bool is_selection_key = IsSelectionKey(Global::Keycode); const bool is_unicode_shift_digit_selection = @@ -873,7 +901,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT || Global::Keycode == VK_BACK || Global::Keycode == VK_DELETE || (Global::Keycode >= 'A' && Global::Keycode <= 'Z') || is_manual_pinyin_separator || is_microsoft_shuangpin_ing_key || is_mid_sentence_helpcode_marker || - is_unicode_hex_digit || is_unicode_plus || is_japanese_long_vowel; + is_direct_helpcode_slash || is_unicode_hex_digit || is_unicode_plus || is_japanese_long_vowel; const bool should_forward_key_to_session = !is_commit_with_highlighted_candidate_punctuation && !is_selection_key && !is_paging_key && !is_composition_edit_key; diff --git a/server/src/ipc/event_listener_pipes.cpp b/server/src/ipc/event_listener_pipes.cpp index c513e07c..29f755d1 100644 --- a/server/src/ipc/event_listener_pipes.cpp +++ b/server/src/ipc/event_listener_pipes.cpp @@ -845,6 +845,9 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand SendToTsfWorkerThreadClientViaNamedpipe( hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, FormatMidSentenceHelpcodeSemicolonWorkerPayload()); + SendToTsfWorkerThreadClientViaNamedpipe( + hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, + FormatDirectHelpcodeWorkerPayload()); SendToTsfWorkerThreadClientViaNamedpipe(hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::InputModeChanged, GetConfiguredInputMode() == "japanese" ? L"1" : L"0"); diff --git a/server/src/session/engine_input_session.cpp b/server/src/session/engine_input_session.cpp index 87d6d568..7a494184 100644 --- a/server/src/session/engine_input_session.cpp +++ b/server/src/session/engine_input_session.cpp @@ -57,6 +57,7 @@ void EngineInputSession::ApplyConfiguration() } session_.set_shuangpin_helpcode_enabled(GetConfiguredShuangpinHelpcodeEnabled()); session_.set_mid_sentence_helpcode_enabled(GetConfiguredShuangpinMidSentenceHelpcodeEnabled()); + session_.set_direct_helpcode_enabled(GetConfiguredShuangpinDirectHelpcodeEnabled()); session_.set_quanpin_helpcode_enabled(GetConfiguredQuanpinHelpcodeEnabled()); const unsigned autocorrect_types = (GetConfiguredQuanpinAutocorrectTransposition() ? quanpin::kAutocorrectTransposition : 0u) | @@ -214,10 +215,16 @@ bool EngineInputSession::accepts_mid_sentence_helpcode_marker(std::size_t caret) { // 开关在 ApplyConfiguration 里随每键重读;这里在吃键之前被问到,那一刻配置可能刚改过, // 所以直接按当前配置判断,不等下一次重读。 - return GetConfiguredShuangpinMidSentenceHelpcodeEnabled() && + return GetConfiguredShuangpinMidSentenceHelpcodeEnabled() && !GetConfiguredShuangpinDirectHelpcodeEnabled() && session_.accepts_mid_sentence_helpcode_marker_at(caret); } +bool EngineInputSession::accepts_direct_helpcode_slash(std::size_t caret) const +{ + // 同上,按当前配置判断。 + return GetConfiguredShuangpinDirectHelpcodeEnabled() && session_.accepts_direct_helpcode_slash_at(caret); +} + bool EngineInputSession::has_mid_sentence_helpcode() const { return session_.has_mid_sentence_helpcode(); diff --git a/server/src/session/engine_input_session.h b/server/src/session/engine_input_session.h index 1acb8917..5bb5a609 100644 --- a/server/src/session/engine_input_session.h +++ b/server/src/session/engine_input_session.h @@ -43,6 +43,7 @@ class EngineInputSession : public IInputSession bool wubi_four_code_is_complete() const override; bool has_active_helpcode() const override; bool accepts_mid_sentence_helpcode_marker(std::size_t caret) const override; + bool accepts_direct_helpcode_slash(std::size_t caret) const override; bool has_mid_sentence_helpcode() const override; std::vector candidates_without_mid_sentence_helpcode() override; diff --git a/server/src/session/input_session.h b/server/src/session/input_session.h index fcbc5bc2..06a5cf98 100644 --- a/server/src/session/input_session.h +++ b/server/src/session/input_session.h @@ -68,6 +68,12 @@ class IInputSession { return false; } + // 光标停在 caret 处时 / 能否作为直接辅助码四码后的终止键插进编码串,语义见 engine + // accepts_direct_helpcode_slash_at。默认不能。 + virtual bool accepts_direct_helpcode_slash(std::size_t) const + { + return false; + } // 当前输入带着生效的句中辅助码约束(候选是筛过的)。默认没有。 virtual bool has_mid_sentence_helpcode() const { diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index a2748afc..fc73dda2 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -663,6 +663,7 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog) {"shuangpin_mid_sentence_helpcode", GetConfiguredShuangpinMidSentenceHelpcodeEnabled()}, {"shuangpin_mid_sentence_helpcode_backtick", GetConfiguredShuangpinMidSentenceHelpcodeBacktick()}, {"shuangpin_mid_sentence_helpcode_semicolon", GetConfiguredShuangpinMidSentenceHelpcodeSemicolon()}, + {"shuangpin_direct_helpcode", GetConfiguredShuangpinDirectHelpcodeEnabled()}, {"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()}, {"quanpin_helpcode", GetConfiguredQuanpinHelpcodeEnabled()}, {"quanpin_helpcode_schema", GetConfiguredQuanpinHelpcodeSchema()}, @@ -1037,6 +1038,8 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredShuangpinMidSentenceHelpcodeBacktick(json::value_to(data.at("value"))); if (path == "helpcode.shuangpin_mid_sentence_helpcode_semicolon") return SetConfiguredShuangpinMidSentenceHelpcodeSemicolon(json::value_to(data.at("value"))); + if (path == "helpcode.shuangpin_direct_helpcode") + return SetConfiguredShuangpinDirectHelpcodeEnabled(json::value_to(data.at("value"))); if (path == "helpcode.shuangpin_helpcode_schema") return SetConfiguredShuangpinHelpcodeSchema(json::value_to(data.at("value"))); if (path == "helpcode.quanpin_helpcode") diff --git a/server/src/webview2/windows_webview2_settings.cpp b/server/src/webview2/windows_webview2_settings.cpp index 068de2bd..af22031e 100644 --- a/server/src/webview2/windows_webview2_settings.cpp +++ b/server/src/webview2/windows_webview2_settings.cpp @@ -67,6 +67,8 @@ static void BroadcastMidSentenceHelpcodeTriggers() BroadcastToTsfWorkerThreadViaNamedpipe( Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, FormatMidSentenceHelpcodeSemicolonWorkerPayload()); + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, + FormatDirectHelpcodeWorkerPayload()); } // 死宿主(InitWebviewSettingsWnd 无调用者)的智能标点子键分发。单独成函数,避免在 @@ -1087,6 +1089,16 @@ static void ApplyHelpcodeSubkey(const std::string &path, const json::object &dat PostSettingsConfig(); } } + if (path == "helpcode.shuangpin_direct_helpcode") + { + const bool value = json::value_to(data.at("value")); + // 直接辅助码开关同时决定反引号/分号触发键是否生效,三个载荷一起重发。 + if (SetConfiguredShuangpinDirectHelpcodeEnabled(value)) + { + BroadcastMidSentenceHelpcodeTriggers(); + PostSettingsConfig(); + } + } if (path == "helpcode.shuangpin_helpcode_schema") { const std::string value = json::value_to(data.at("value")); @@ -1714,6 +1726,7 @@ void PostSettingsConfig() {"shuangpin_mid_sentence_helpcode", GetConfiguredShuangpinMidSentenceHelpcodeEnabled()}, {"shuangpin_mid_sentence_helpcode_backtick", GetConfiguredShuangpinMidSentenceHelpcodeBacktick()}, {"shuangpin_mid_sentence_helpcode_semicolon", GetConfiguredShuangpinMidSentenceHelpcodeSemicolon()}, + {"shuangpin_direct_helpcode", GetConfiguredShuangpinDirectHelpcodeEnabled()}, {"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()}, {"quanpin_helpcode", GetConfiguredQuanpinHelpcodeEnabled()}, {"quanpin_helpcode_schema", GetConfiguredQuanpinHelpcodeSchema()}, diff --git a/server/src/window/ime_windows.cpp b/server/src/window/ime_windows.cpp index 14ef403c..e0ba1d47 100644 --- a/server/src/window/ime_windows.cpp +++ b/server/src/window/ime_windows.cpp @@ -271,6 +271,8 @@ void ApplyConfiguredInputScheme() BroadcastToTsfWorkerThreadViaNamedpipe( Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, FormatMidSentenceHelpcodeSemicolonWorkerPayload()); + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, + FormatDirectHelpcodeWorkerPayload()); // 「双拼显示全拼」让原始按键样式在双拼下改由 Server 回包驱动,换方案时 TSF 要跟着换。 BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::PagingCommaPeriodChanged, FormatPagingCommaPeriodWorkerPayload()); diff --git a/server/src/window/ime_windows_candidate.cpp b/server/src/window/ime_windows_candidate.cpp index 05d70a98..45ea371a 100644 --- a/server/src/window/ime_windows_candidate.cpp +++ b/server/src/window/ime_windows_candidate.cpp @@ -522,6 +522,7 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA const std::wstring previous_mid_sentence_helpcode = FormatMidSentenceHelpcodeWorkerPayload(); const std::wstring previous_mid_sentence_helpcode_semicolon = FormatMidSentenceHelpcodeSemicolonWorkerPayload(); + const std::wstring previous_direct_helpcode = FormatDirectHelpcodeWorkerPayload(); const std::wstring previous_paging_worker_payload = FormatPagingCommaPeriodWorkerPayload(); const std::string previous_theme_mode = GetConfiguredThemeMode(); const std::string previous_theme_cand = GetConfiguredThemeCand(); @@ -691,6 +692,12 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, FormatMidSentenceHelpcodeSemicolonWorkerPayload()); } + if (previous_direct_helpcode != FormatDirectHelpcodeWorkerPayload()) + { + BroadcastToTsfWorkerThreadViaNamedpipe( + Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, + FormatDirectHelpcodeWorkerPayload()); + } const VoiceInputConfig &voice_input = GetConfiguredVoiceInput(); if (previous_voice_input.enabled != voice_input.enabled || previous_voice_input.hotkey_ralt != voice_input.hotkey_ralt || diff --git a/server/src/window/ime_windows_settings.cpp b/server/src/window/ime_windows_settings.cpp index 04ff633c..9e186fab 100644 --- a/server/src/window/ime_windows_settings.cpp +++ b/server/src/window/ime_windows_settings.cpp @@ -358,6 +358,7 @@ LRESULT CALLBACK WndProcSettingsWindow(HWND hwnd, UINT message, WPARAM wParam, L const std::wstring previous_mid_sentence_helpcode = FormatMidSentenceHelpcodeWorkerPayload(); const std::wstring previous_mid_sentence_helpcode_semicolon = FormatMidSentenceHelpcodeSemicolonWorkerPayload(); + const std::wstring previous_direct_helpcode = FormatDirectHelpcodeWorkerPayload(); const std::wstring previous_paging_worker_payload = FormatPagingCommaPeriodWorkerPayload(); const std::string previous_theme_mode = GetConfiguredThemeMode(); const std::string previous_theme_cand = GetConfiguredThemeCand(); @@ -516,6 +517,12 @@ LRESULT CALLBACK WndProcSettingsWindow(HWND hwnd, UINT message, WPARAM wParam, L Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, FormatMidSentenceHelpcodeSemicolonWorkerPayload()); } + if (previous_direct_helpcode != FormatDirectHelpcodeWorkerPayload()) + { + BroadcastToTsfWorkerThreadViaNamedpipe( + Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, + FormatDirectHelpcodeWorkerPayload()); + } PostSettingsConfig(); } FanyNamedPipe::EnqueueEnsureInputSessionMatchesConfigTask(); diff --git a/server/tests/src/test_engine_shuangpin_session.cpp b/server/tests/src/test_engine_shuangpin_session.cpp index 5f193ae1..dc73a37c 100644 --- a/server/tests/src/test_engine_shuangpin_session.cpp +++ b/server/tests/src/test_engine_shuangpin_session.cpp @@ -1426,6 +1426,61 @@ TEST_CASE(SegmentBoundariesKeepHelpcodeAndJianpinTailAsTheirOwnUnits) REQUIRE_EQ(jianpin.segment_raw_boundaries(), std::vector({0, 3, 4})); } +TEST_CASE(EngineShuangpinDirectHelpcodeDecodesWithoutGuideKey) +{ + // 直接辅助码(万象式)走 Server 的真实路径:整串写回会话再重算,真实词库、词格与 Google 整句都开着。 + struct ReloadConfigOnExit + { + ~ReloadConfigOnExit() + { + InitImeConfig(); + } + } reload; + ScopedConfigRoot config_root; + InitImeConfig(); + REQUIRE(SetConfiguredShuangpinMidSentenceHelpcodeEnabled(true)); + REQUIRE(SetConfiguredShuangpinDirectHelpcodeEnabled(true)); + REQUIRE(SetConfiguredShuangpinHelpcodeSchema("ziranma")); + REQUIRE(SetConfiguredAssocSentenceWordLattice(true)); + REQUIRE(SetConfiguredAssocSentenceGoogle(true)); + const bool shuangpin_active = GetConfiguredInputScheme() == SchemeType::Shuangpin; + REQUIRE_EQ(FormatDirectHelpcodeWorkerPayload(), std::wstring(shuangpin_active ? L"1" : L"0")); + // 直接辅助码接管辅码:句中辅助码的触发键跟着失效,TSF 收到的载荷也是 "0"。 + REQUIRE(!IsConfiguredMidSentenceHelpcodeTrigger(L'`')); + REQUIRE_EQ(FormatMidSentenceHelpcodeWorkerPayload(), std::wstring(L"0")); + + EngineInputSession session(SchemeType::Shuangpin, GetXiaoheShuangpinProfile()); + const auto first_code = [&session](const std::string &hanzi) { + const std::string annotation = session.get_helpcode_annotation(hanzi, false); + return annotation.size() > 1 ? static_cast(std::tolower(static_cast(annotation[1]))) + : '\0'; + }; + const auto apply = [&session](const std::string &raw) { + session.set_pinyin_sequence(raw); + session.set_pinyin_sequence_with_cases(raw); + session.recompute_candidates(); + }; + const char stone = first_code("石"); + const char lion = first_code("狮"); + REQUIRE(stone != '\0' && lion != '\0'); + + // 小鹤 shi = ui:三码不要引导键,uiX uiY 直接解成 石狮。 + const std::string typed = std::string("ui") + stone + "ui" + lion; + apply(typed); + const auto &candidates = session.get_candidates(); + REQUIRE(std::any_of(candidates.begin(), candidates.end(), [](const auto &item) { return item.word == "石狮"; })); + REQUIRE(!candidates.empty() && first_code(HelpcodeUtils::get_first_han_char(candidates.front().word)) == stone); + REQUIRE_EQ(session.get_pinyin_sequence_with_cases(), typed); + + // 四码后的 / 只在「两键 + 两码」之后收。 + apply("uiab"); + REQUIRE(session.accepts_direct_helpcode_slash(4)); + REQUIRE(!session.accepts_direct_helpcode_slash(3)); + REQUIRE(SetConfiguredShuangpinDirectHelpcodeEnabled(false)); + REQUIRE(!session.accepts_direct_helpcode_slash(4)); + REQUIRE_EQ(FormatDirectHelpcodeWorkerPayload(), std::wstring(L"0")); +} + TEST_CASE(EngineShuangpinMidSentenceHelpcodeConstrainsSentenceSources) { // 句中辅助码走 Server 的真实路径:整串写回会话再重算。词格和 Google 整句都打开,约束要在 diff --git a/server/tests/src/test_ipc_protocol_constants.cpp b/server/tests/src/test_ipc_protocol_constants.cpp index 6c1e4b5d..0d950d57 100644 --- a/server/tests/src/test_ipc_protocol_constants.cpp +++ b/server/tests/src/test_ipc_protocol_constants.cpp @@ -57,7 +57,8 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply) // otherwise misread a new frame. New frames take the next free number. REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeChanged, 28u); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, 29u); - REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 29u); + REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, 30u); + REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 30u); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady > Global::DataFromServerMsgTypeToTsfWorkerThread::PagingCommaPeriodChanged); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady > @@ -100,8 +101,10 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply) Global::DataFromServerMsgTypeToTsfWorkerThread::CommitCandidateAndContinue); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged > Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeChanged); + REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged > + Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, - Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged); + Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged); } TEST_CASE(ipc_client_suspension_is_a_distinct_nonterminal_route_reset) diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index 8f877e23..168e3fd3 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -331,6 +331,9 @@ function applyConfigData(data: Record, target?: string): void { if (typeof data?.helpcode?.shuangpin_mid_sentence_helpcode === 'boolean') { applyToggleState('midSentenceHelpcodeToggleBtn', data.helpcode.shuangpin_mid_sentence_helpcode); } + if (typeof data?.helpcode?.shuangpin_direct_helpcode === 'boolean') { + applyToggleState('directHelpcodeToggleBtn', data.helpcode.shuangpin_direct_helpcode); + } if (typeof data?.helpcode?.shuangpin_mid_sentence_helpcode_backtick === 'boolean') { const checkbox = findElement('midSentenceHelpcodeBacktickCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.helpcode.shuangpin_mid_sentence_helpcode_backtick; diff --git a/ui-html/webview2/settings/ime-settings/src/modules/helpcode.ts b/ui-html/webview2/settings/ime-settings/src/modules/helpcode.ts index 397e69fe..35fd973a 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/helpcode.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/helpcode.ts @@ -63,6 +63,11 @@ export function setupHelpcode(): void { updateConfig('helpcode.shuangpin_mid_sentence_helpcode', active); }); + // 双拼直接辅助码(万象式)开关 + setupToggleButton('directHelpcodeToggleBtn', (active) => { + updateConfig('helpcode.shuangpin_direct_helpcode', active); + }); + // 句中辅助码触发键,可多选;至少留一个,最后一个取消不掉 const triggerCheckboxes: [string, string][] = [ ['midSentenceHelpcodeBacktickCheckbox', 'helpcode.shuangpin_mid_sentence_helpcode_backtick'], diff --git a/ui-html/webview2/settings/ime-settings/src/partials/helpcode.html b/ui-html/webview2/settings/ime-settings/src/partials/helpcode.html index f95471b5..7987656f 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/helpcode.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/helpcode.html @@ -51,6 +51,17 @@
至少保留一个。分号在微软、搜狗、紫光双拼里也是 ing 韵母,只有在完整音节后才当触发键;按下后预编辑里同样显示为 `
+ + +
+
直接辅助码(万象式)
+
+
+
+
+
+
开启后末尾辅助码与句中辅助码的触发键都不再生效,由它统一识别
+
diff --git a/windows/src/Composition/CompositionProcessorEngine.h b/windows/src/Composition/CompositionProcessorEngine.h index 9e5f1485..27910002 100644 --- a/windows/src/Composition/CompositionProcessorEngine.h +++ b/windows/src/Composition/CompositionProcessorEngine.h @@ -49,6 +49,10 @@ class CCompositionProcessorEngine // engine/contracts/mid_sentence_helpcode.h。分号先让给 ing 韵母。 static bool IsMidSentenceHelpcodeTriggerKey(UINT uCode, WCHAR wch, _In_reads_opt_(length) const WCHAR *buffer, DWORD_PTR length, DWORD_PTR caret); + // 双拼直接辅助码开着时额外收的编码键:四码后的 /,以及不看奇偶的 ; 韵母。规则见 + // engine/contracts/direct_helpcode.h。 + static bool IsDirectHelpcodeInputKey(UINT uCode, WCHAR wch, _In_reads_opt_(length) const WCHAR *buffer, + DWORD_PTR length, DWORD_PTR caret); // 当句中辅助码触发键的分号在缓冲里记成反引号(与 Server 的 raw 一致),其余字符原样返回。 static WCHAR NormalizeMidSentenceHelpcodeTrigger(WCHAR wch, _In_reads_opt_(length) const WCHAR *buffer, DWORD_PTR length, DWORD_PTR caret); diff --git a/windows/src/Composition/CompositionProcessorEngine_KeyClassify.cpp b/windows/src/Composition/CompositionProcessorEngine_KeyClassify.cpp index 5290ff18..be87a2a5 100644 --- a/windows/src/Composition/CompositionProcessorEngine_KeyClassify.cpp +++ b/windows/src/Composition/CompositionProcessorEngine_KeyClassify.cpp @@ -19,6 +19,7 @@ #include "FanyLog.h" #include "EditSession.h" #include "TfTextLayoutSink.h" +#include "../../../engine/contracts/direct_helpcode.h" #include "../../../engine/contracts/mid_sentence_helpcode.h" #include @@ -33,6 +34,12 @@ bool IsMicrosoftShuangpinIngKeyAt(UINT uCode, WCHAR wch, const WCHAR *buffer, DW return false; } caret = min(caret, length); + // 直接辅助码的辅码会打乱奇偶(uia 后面接 x; 时这一节是偶数键),改成只看前一个键是不是字母。 + if (Global::DirectHelpcodeEnabled.load(std::memory_order_relaxed)) + { + return FanyImeDirectHelpcode::AcceptsSemicolonFinalAt(buffer, static_cast(length), + static_cast(caret)); + } DWORD_PTR chunkLength = 0; for (DWORD_PTR index = caret; index > 0 && buffer[index - 1] != L'\''; --index) { @@ -41,6 +48,16 @@ bool IsMicrosoftShuangpinIngKeyAt(UINT uCode, WCHAR wch, const WCHAR *buffer, DW return chunkLength % 2 == 1; } +// 双拼直接辅助码的 /:四码(两键音节 + 两位辅码)后面的终止键是编码键,否则仍按标点处理。Server +// 用同一条形状规则决定收不收(engine/contracts/direct_helpcode.h)。 +bool IsDirectHelpcodeSlashKey(UINT uCode, WCHAR wch, const WCHAR *buffer, DWORD_PTR length, DWORD_PTR caret) +{ + return uCode == VK_OEM_2 && wch == L'/' && Global::DirectHelpcodeEnabled.load(std::memory_order_relaxed) && + buffer != nullptr && length > 0 && + FanyImeDirectHelpcode::AcceptsSlashAt(buffer, static_cast(length), + static_cast(min(caret, length))); +} + // 双拼句中辅助码的触发键(反引号,或设置里勾了的分号):开关开着,且光标前是一节完整的两键音节时 // 是编码键,否则仍按标点处理。分号先让给 ing 韵母。光标可以在句中(用箭头移回去补辅助码),只看 // 光标前的部分。Server 用同一条形状规则决定收不收(engine/contracts/mid_sentence_helpcode.h)。 @@ -336,7 +353,9 @@ BOOL CCompositionProcessorEngine::IsVirtualKeyNeed( // } if (IsMidSentenceHelpcodeMarkerKey(uCode, pwch ? *pwch : 0, _keystrokeBuffer.Get(), _keystrokeBuffer.GetLength(), - _caretPosition)) + _caretPosition) || + IsDirectHelpcodeSlashKey(uCode, pwch ? *pwch : 0, _keystrokeBuffer.Get(), _keystrokeBuffer.GetLength(), + _caretPosition)) { if (pKeyState) { @@ -853,6 +872,14 @@ bool CCompositionProcessorEngine::IsMidSentenceHelpcodeTriggerKey(UINT uCode, WC return IsMidSentenceHelpcodeMarkerKey(uCode, wch, buffer, length, caret); } +bool CCompositionProcessorEngine::IsDirectHelpcodeInputKey(UINT uCode, WCHAR wch, const WCHAR *buffer, DWORD_PTR length, + DWORD_PTR caret) +{ + return IsDirectHelpcodeSlashKey(uCode, wch, buffer, length, caret) || + (Global::DirectHelpcodeEnabled.load(std::memory_order_relaxed) && + IsMicrosoftShuangpinIngKeyAt(uCode, wch, buffer, length, caret)); +} + // 分号触发的句中辅助码段在按键缓冲里记成反引号,与 Server 的 raw 一致。在加入缓冲之前、按与吃键 // 预判相同的状态判断。 WCHAR CCompositionProcessorEngine::NormalizeMidSentenceHelpcodeTrigger(WCHAR wch, const WCHAR *buffer, DWORD_PTR length, diff --git a/windows/src/IME/MetasequoiaIME_IpcWorker.cpp b/windows/src/IME/MetasequoiaIME_IpcWorker.cpp index d1f01bcd..6ba331f9 100644 --- a/windows/src/IME/MetasequoiaIME_IpcWorker.cpp +++ b/windows/src/IME/MetasequoiaIME_IpcWorker.cpp @@ -407,6 +407,7 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME) buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MicrosoftShuangpinChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MidSentenceHelpcodeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MidSentenceHelpcodeSemicolonChanged || + buf.msg_type == Global::DataToTsfWorkerThreadMsgType::DirectHelpcodeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::InputModeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::CapsLockChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::TsfDiagnosticLogChanged || @@ -491,6 +492,7 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME) buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MicrosoftShuangpinChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MidSentenceHelpcodeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MidSentenceHelpcodeSemicolonChanged || + buf.msg_type == Global::DataToTsfWorkerThreadMsgType::DirectHelpcodeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::InputModeChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::CapsLockChanged || buf.msg_type == Global::DataToTsfWorkerThreadMsgType::TsfDiagnosticLogChanged || @@ -617,6 +619,10 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME) { Global::MidSentenceHelpcodeSemicolonEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed); } + else if (buf.msg_type == Global::DataToTsfWorkerThreadMsgType::DirectHelpcodeChanged) + { + Global::DirectHelpcodeEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed); + } else if (buf.msg_type == Global::DataToTsfWorkerThreadMsgType::InputModeChanged) { Global::JapaneseInputModeEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed); diff --git a/windows/src/IPC/Ipc.h b/windows/src/IPC/Ipc.h index 7f501dcb..946f5e2d 100644 --- a/windows/src/IPC/Ipc.h +++ b/windows/src/IPC/Ipc.h @@ -214,6 +214,9 @@ inline std::atomic_bool MicrosoftShuangpinEnabled{false}; inline std::atomic_bool MidSentenceHelpcodeEnabled{false}; // 同上,换成分号触发键(设置里多选)。分号触发的段在按键缓冲里同样记成反引号。 inline std::atomic_bool MidSentenceHelpcodeSemicolonEnabled{false}; +// 双拼直接辅助码(万象式)开着且当前是双拼:四码后的 / 当编码键吃掉,; 韵母不再看奇偶,规则见 +// engine/contracts/direct_helpcode.h。 +inline std::atomic_bool DirectHelpcodeEnabled{false}; inline std::atomic_bool JapaneseInputModeEnabled{false}; inline std::atomic_bool CapsLockEnabled{false}; inline std::atomic_bool TsfDiagnosticLogEnabled{false}; diff --git a/windows/src/Key/KeyEventSink_DeferredKeys.cpp b/windows/src/Key/KeyEventSink_DeferredKeys.cpp index 161ed48e..f6936d39 100644 --- a/windows/src/Key/KeyEventSink_DeferredKeys.cpp +++ b/windows/src/Key/KeyEventSink_DeferredKeys.cpp @@ -520,8 +520,11 @@ bool CMetasequoiaIME::_ClassifyDeferredKeyDown(_In_ ITfContext *pContext, WPARAM { isInputKey = true; } + // 直接辅助码开着时 ; 韵母不看奇偶(辅码会打乱奇偶),交给下面的 IsDirectHelpcodeInputKey,与同步路径和 + // Server 用同一条规则;这里再按奇偶放行,uiab/; 这类输入两边就会分叉。 if (!isInputKey && Global::MicrosoftShuangpinEnabled.load(std::memory_order_relaxed) && - *classifiedCode == VK_OEM_1 && *classifiedWch == L';' && !shadow.rawInput.empty()) + !Global::DirectHelpcodeEnabled.load(std::memory_order_relaxed) && *classifiedCode == VK_OEM_1 && + *classifiedWch == L';' && !shadow.rawInput.empty()) { const size_t caret = min(shadow.caret, shadow.rawInput.size()); const size_t separator = caret == 0 ? std::wstring::npos : shadow.rawInput.rfind(L'\'', caret - 1); @@ -534,6 +537,12 @@ bool CMetasequoiaIME::_ClassifyDeferredKeyDown(_In_ ITfContext *pContext, WPARAM isInputKey = CCompositionProcessorEngine::IsMidSentenceHelpcodeTriggerKey( *classifiedCode, *classifiedWch, shadow.rawInput.data(), shadow.rawInput.size(), shadow.caret); } + // 直接辅助码的 / 与不看奇偶的 ; 韵母,同样按影子状态算。 + if (!isInputKey) + { + isInputKey = CCompositionProcessorEngine::IsDirectHelpcodeInputKey( + *classifiedCode, *classifiedWch, shadow.rawInput.data(), shadow.rawInput.size(), shadow.caret); + } if (shadow.inputLength == 0 && (GetKeyState(VK_CAPITAL) & 0x0001) != 0 && *classifiedWch >= L'A' && *classifiedWch <= L'Z' && *classifiedCode >= L'A' && *classifiedCode <= L'Z') {