#include "autocorrect.h" #include #include #include namespace ks { static bool openingDelimiter(wchar_t c, const Settings &settings) { return settings.autoPrefixChars.find(c) != std::wstring::npos; } static bool trailingDelimiter(wchar_t c, const Settings &settings) { return c == L' ' || settings.autoSuffixChars.find(c) != std::wstring::npos; } bool autoClosingSelection(const std::wstring &text, const Settings &settings) { return !text.empty() && text.size() <= 6 && std::all_of(text.begin(), text.end(), [&](wchar_t c) { return trailingDelimiter(c, settings); }) && text.find_first_not_of(L' ') != text.npos; } std::wstring layoutLanguage(const Layout &layout) { if (layout.id == L"en" || layout.id == L"uk" || layout.id == L"ru") return layout.id; switch (PRIMARYLANGID(LOWORD((uintptr_t)layout.handle))) { case LANG_ENGLISH: return L"en"; case LANG_UKRAINIAN: return L"uk"; case LANG_RUSSIAN: return L"ru"; default: return {}; } } bool safeAutoWord(const std::wstring &word, int minimumLength) { if (minimumLength < 1 || minimumLength > 32 || word.size() < (size_t)minimumLength || word.size() > 32 || !std::all_of(word.begin(), word.end(), letter)) return false; auto small = lower(word); return word == small || (word.substr(1) == small.substr(1) && word != upper(word)); } namespace { constexpr size_t DictionaryBlockWords = 32; } std::string_view AutoCorrector::Dictionary::word(size_t block) const { size_t start = offsets[block]; // Every block starts with a complete word (zero shared prefix). size_t length = static_cast(bytes[start++]) & 15; if (length == 15) length = static_cast(bytes[start++]); return std::string_view(bytes).substr(start, length); } AutoCorrector::Dictionary::Dictionary(const std::filesystem::path &path) { const auto raw = readFile(path, 2 * 1024 * 1024); const std::string_view source(reinterpret_cast(raw.data()), raw.size()); std::vector lines; lines.reserve(std::count(source.begin(), source.end(), '\n') + 1); for (size_t start = 0; start < source.size();) { auto end = source.find('\n', start); if (end == source.npos) end = source.size(); auto tab = source.find('\t', start); if (tab == source.npos || tab >= end || tab == start || tab - start > 160) throw std::runtime_error("Invalid auto-correction dictionary"); int rank{}; auto rankEnd = end > tab + 1 && source[end - 1] == '\r' ? end - 1 : end; auto parse = std::from_chars(source.data() + tab + 1, source.data() + rankEnd, rank); if (parse.ec != std::errc() || parse.ptr != source.data() + rankEnd || rank <= 0) throw std::runtime_error("Invalid dictionary rank"); lines.push_back(static_cast(start)); start = end + 1; } if (lines.empty()) throw std::runtime_error("Empty auto-correction dictionary"); auto entry = [&](uint32_t offset) { return source.substr(offset, source.find('\t', offset) - offset); }; auto frequency = [&](uint32_t offset) { int rank{}; auto start = offset + entry(offset).size() + 1; std::from_chars(source.data() + start, source.data() + source.size(), rank); return rank; }; std::sort(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) { return entry(a) == entry(b) ? frequency(a) < frequency(b) : entry(a) < entry(b); }); lines.erase(std::unique(lines.begin(), lines.end(), [&](uint32_t a, uint32_t b) { return entry(a) == entry(b); }), lines.end()); offsets.reserve((lines.size() + DictionaryBlockWords - 1) / DictionaryBlockWords); std::string_view previous; // Allocate the packed payload exactly once. Geometric growth and a final // shrink otherwise leave discarded buffers in the resident process heap. size_t packedSize = 0; for (size_t i = 0; i < lines.size(); ++i) { auto current = entry(lines[i]); size_t prefix = 0; if (i % DictionaryBlockWords != 0) while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix; auto suffix = current.size() - prefix; packedSize += 1 + (prefix >= 15) + (suffix >= 15) + suffix + 2; auto rank = static_cast(frequency(lines[i])); if (rank >= 65535) do { ++packedSize; rank >>= 7; } while (rank); previous = current; } bytes.reserve(packedSize); previous = {}; for (size_t i = 0; i < lines.size(); ++i) { auto current = entry(lines[i]); size_t prefix = 0; if (i % DictionaryBlockWords == 0) offsets.push_back(static_cast(bytes.size())); else while (prefix < std::min(previous.size(), current.size()) && previous[prefix] == current[prefix]) ++prefix; size_t suffix = current.size() - prefix; bytes.push_back(static_cast((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15)))); if (prefix >= 15) bytes.push_back(static_cast(prefix)); if (suffix >= 15) bytes.push_back(static_cast(suffix)); bytes.append(current.substr(prefix)); auto rank = static_cast(frequency(lines[i])); auto shortRank = std::min(rank, uint32_t(65535)); bytes.push_back(static_cast(shortRank & 255)); bytes.push_back(static_cast(shortRank >> 8)); if (rank >= 65535) do { auto next = rank & 127; rank >>= 7; bytes.push_back(static_cast(next | (rank ? 128 : 0))); } while (rank); previous = current; } } int AutoCorrector::Dictionary::rank(const std::wstring &text) const { auto key = utf8(lower(text)); size_t begin = 0, end = offsets.size(); // Find the last block whose first word is <= the search key. while (begin < end) { auto mid = begin + (end - begin) / 2; if (word(mid) <= key) begin = mid + 1; else end = mid; } if (!begin) return 0; size_t block = begin - 1; size_t at = offsets[block]; const size_t limit = begin < offsets.size() ? offsets[begin] : bytes.size(); char decoded[160]; // The loader validates lengths; lookup never allocates a decoded word. while (at < limit) { auto lengths = static_cast(bytes[at++]); size_t prefix = lengths >> 4, suffix = lengths & 15; if (prefix == 15) prefix = static_cast(bytes[at++]); if (suffix == 15) suffix = static_cast(bytes[at++]); memcpy(decoded + prefix, bytes.data() + at, suffix); at += suffix; uint32_t rank = static_cast(bytes[at]) | (uint32_t(static_cast(bytes[at + 1])) << 8); at += 2; if (rank == 65535) { rank = 0; uint32_t shift = 0; unsigned char part; do { part = static_cast(bytes[at++]); rank |= uint32_t(part & 127) << shift; shift += 7; } while (part & 128); } auto compared = std::string_view(decoded, size_t(prefix) + suffix).compare(key); if (compared == 0) return static_cast(rank); if (compared > 0) return 0; } return 0; } void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector &wanted) { if (languages == wanted) return; std::map> next; for (auto &lang : wanted) { if (lang != L"en" && lang != L"uk" && lang != L"ru") throw std::runtime_error("Unsupported dictionary language"); next.emplace(lang, std::make_shared(folder / (lang + L".txt"))); } dictionaries = std::move(next); languages = wanted; } void AutoCorrector::shareDictionaries(const AutoCorrector &source) { dictionaries = source.dictionaries; languages = source.languages; } void AutoCorrector::reject(std::wstring word, const Settings &settings) { while (!word.empty() && openingDelimiter(word.front(), settings)) word.erase(word.begin()); while (!word.empty() && trailingDelimiter(word.back(), settings)) word.pop_back(); if (rejected.size() >= 128) rejected.clear(); rejected.insert(lower(word)); } size_t AutoCorrector::memoryBytes() const { size_t bytes = 0; for (auto &[language, d] : dictionaries) bytes += d->bytes.capacity() + d->offsets.capacity() * sizeof(uint32_t); return bytes; } std::optional AutoCorrector::suggest(const std::wstring &typed, int source, const std::vector &layouts, const Settings &settings) const { if (source < 0 || source >= (int)layouts.size() || !layouts[source].convertible) return {}; std::wstring word = typed, suffix, prefix; while (!word.empty() && openingDelimiter(word.front(), settings) && prefix.size() < 3) { prefix += word.front(); word.erase(word.begin()); } while (!word.empty() && trailingDelimiter(word.back(), settings)) { suffix.insert(suffix.begin(), word.back()); word.pop_back(); } // Punctuation keys can be letters in the target layout (for example ',' -> 'б'). // Validate letters and capitalization after conversion, not before it. if (settings.autoMinLength < 1 || settings.autoMinLength > 32 || word.size() < (size_t)settings.autoMinLength || word.size() > 32 || suffix.size() > 6 || rejected.contains(lower(word))) return {}; for (auto &never : settings.autoNever) if (lower(never) == lower(word)) return {}; auto sourceLanguage = layoutLanguage(layouts[source]); if (!dictionaries.contains(sourceLanguage)) return {}; const std::wstring alphabet = sourceLanguage == L"en" ? L"abcdefghijklmnopqrstuvwxyz" : sourceLanguage == L"uk" ? L"абвгґдеєжзиіїйклмнопрстуфхцчшщьюя" : L"абвгдеёжзийклмнопрстуфхцчшщъыьэюя"; auto small = lower(word); bool sameLanguage = safeAutoWord(word, settings.autoMinLength) && small.find_first_not_of(alphabet) == small.npos && std::find(settings.cycle.begin(), settings.cycle.end(), layouts[source].id) != settings.cycle.end(); auto sameLayout = [&](std::wstring text) { if (word != small) text[0] = upper(text.substr(0, 1))[0]; return AutoSuggestion{source, source, prefix + text + suffix}; }; if (settings.autoReplace && sameLanguage) { auto replacement = settings.autoReplacements.find(small); if (replacement != settings.autoReplacements.end() && replacement->second.find_first_not_of(alphabet) == small.npos) return sameLayout(replacement->second); } auto personal = [&](const std::wstring &language, const std::wstring &candidate) { auto found = settings.autoWords.find(language); return found != settings.autoWords.end() && std::any_of(found->second.begin(), found->second.end(), [&](const std::wstring &known) { return lower(known) == lower(candidate); }); }; // Valid words from either selected language are never changed. for (auto &[language, d] : dictionaries) if (d->rank(word) || personal(language, word)) return {}; std::optional candidate; std::optional matchedText; auto priority = [&](int layout) { auto language = layoutLanguage(layouts[layout]); return std::find(settings.autoLanguages.begin(), settings.autoLanguages.end(), language) - settings.autoLanguages.begin(); }; for (int i = 0; i < (int)layouts.size(); ++i) { if (i == source || !layouts[i].convertible || std::find(settings.cycle.begin(), settings.cycle.end(), layouts[i].id) == settings.cycle.end()) continue; auto dictionary = dictionaries.find(layoutLanguage(layouts[i])); if (dictionary == dictionaries.end()) continue; auto converted = convert(word, layouts[source], layouts[i], true); if (converted == word || !safeAutoWord(converted, settings.autoMinLength)) continue; int rank = personal(dictionary->first, converted) ? 1 : dictionary->second->rank(converted); if (!rank) continue; if (matchedText && *matchedText != converted) return {}; matchedText = converted; if ((settings.autoMaxRank == 0 || rank <= settings.autoMaxRank) && (!candidate || priority(i) < priority(candidate->target))) candidate = AutoSuggestion{source, i, prefix + converted + suffix}; } if (candidate || matchedText || !settings.autoTypos || !sameLanguage || word.size() < 4) return candidate; // Generate only single edits. Stop at a second distinct dictionary hit, // including rare hits: frequency limits must never hide ambiguity. std::wstring found; int foundRank = 0; std::set personalTargets; if (auto it = settings.autoWords.find(sourceLanguage); it != settings.autoWords.end()) for (const auto &text : it->second) personalTargets.insert(lower(text)); auto consider = [&](const std::wstring &text) { if (text == small || text == found || text.size() < 4 || text.size() > 32) return true; int rank = personalTargets.contains(text) ? 1 : dictionaries.at(sourceLanguage)->rank(text); if (!rank) return true; if (!found.empty()) return false; found = text; foundRank = rank; return true; }; for (size_t i = 0; i < small.size(); ++i) { auto text = small; text.erase(i, 1); if (!consider(text)) return {}; if (i + 1 < small.size()) { text = small; std::swap(text[i], text[i + 1]); if (!consider(text)) return {}; } for (wchar_t c : alphabet) { text = small; text[i] = c; if (!consider(text)) return {}; } } if (small.size() < 32) for (size_t i = 0; i <= small.size(); ++i) for (wchar_t c : alphabet) { auto text = small; text.insert(i, 1, c); if (!consider(text)) return {}; } if (!found.empty() && (settings.autoMaxRank == 0 || foundRank <= settings.autoMaxRank)) return sameLayout(found); return {}; } int matchAutoInput(const Run &run, const std::wstring &text, const std::vector &layouts, const Layout &physical, const Settings &settings) { int full = run.match(text, layouts, physical); if (full >= 0 || run.overflow || text.empty()) return full; // Ctrl+Shift+Left may omit opening delimiters. Only these delimiters may // be omitted; never accept an arbitrary matching suffix of a longer word. for (int i = 0; i < (int)layouts.size(); ++i) { if (!layouts[i].convertible) continue; std::wstring expected; for (wchar_t c : run.keys) expected += c == L' ' ? L' ' : layouts[i].at(physical.code(c)); for (int removed = 0; removed < 3 && !expected.empty() && openingDelimiter(expected.front(), settings); ++removed) { expected.erase(expected.begin()); if (lower(expected) == lower(text)) return i; } } return -1; } bool autoCorrectionAllowed(HWND target, const Settings &settings) { if (!target || GetForegroundWindow() != target) return false; auto process = lower(processName(target)); for (auto &excluded : settings.autoExcluded) { auto name = lower(excluded); if (name.ends_with(L".exe")) name.resize(name.size() - 4); if (name == process) return false; } // Fail closed: do not copy from password fields or inaccessible controls. IUIAutomation2 *automation{}; if (FAILED(CoCreateInstance(CLSID_CUIAutomation8, nullptr, CLSCTX_INPROC_SERVER, IID_IUIAutomation2, (void **)&automation))) return false; automation->put_ConnectionTimeout(200); automation->put_TransactionTimeout(200); IUIAutomationElement *focused{}; HRESULT hr = automation->GetFocusedElement(&focused); automation->Release(); if (FAILED(hr) || !focused) return false; BOOL password = TRUE, enabled = FALSE, hasFocus = FALSE; CONTROLTYPEID type{}; bool allowed = SUCCEEDED(focused->get_CurrentIsPassword(&password)) && !password && SUCCEEDED(focused->get_CurrentIsEnabled(&enabled)) && enabled && SUCCEEDED(focused->get_CurrentHasKeyboardFocus(&hasFocus)) && hasFocus && SUCCEEDED(focused->get_CurrentControlType(&type)) && (type == UIA_EditControlTypeId || type == UIA_DocumentControlTypeId); focused->Release(); return allowed && GetForegroundWindow() == target; } } // namespace ks