Files
hrynco-kswitcher/src/autocorrect.cpp
T

412 lines
17 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "autocorrect.h"
#include <algorithm>
#include <charconv>
#include <uiautomation.h>
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<unsigned char>(bytes[start++]) & 15;
if (length == 15) length = static_cast<unsigned char>(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<const char *>(raw.data()), raw.size());
std::vector<uint32_t> 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<uint32_t>(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<uint32_t>(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<uint32_t>(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<char>((std::min(prefix, size_t(15)) << 4) | std::min(suffix, size_t(15))));
if (prefix >= 15) bytes.push_back(static_cast<char>(prefix));
if (suffix >= 15) bytes.push_back(static_cast<char>(suffix));
bytes.append(current.substr(prefix));
auto rank = static_cast<uint32_t>(frequency(lines[i]));
auto shortRank = std::min(rank, uint32_t(65535));
bytes.push_back(static_cast<char>(shortRank & 255));
bytes.push_back(static_cast<char>(shortRank >> 8));
if (rank >= 65535)
do {
auto next = rank & 127;
rank >>= 7;
bytes.push_back(static_cast<char>(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<unsigned char>(bytes[at++]);
size_t prefix = lengths >> 4, suffix = lengths & 15;
if (prefix == 15) prefix = static_cast<unsigned char>(bytes[at++]);
if (suffix == 15) suffix = static_cast<unsigned char>(bytes[at++]);
memcpy(decoded + prefix, bytes.data() + at, suffix);
at += suffix;
uint32_t rank = static_cast<unsigned char>(bytes[at]) |
(uint32_t(static_cast<unsigned char>(bytes[at + 1])) << 8);
at += 2;
if (rank == 65535) {
rank = 0;
uint32_t shift = 0;
unsigned char part;
do {
part = static_cast<unsigned char>(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<int>(rank);
if (compared > 0) return 0;
}
return 0;
}
void AutoCorrector::configure(const std::filesystem::path &folder, const std::vector<std::wstring> &wanted)
{
if (languages == wanted)
return;
std::map<std::wstring, std::shared_ptr<const Dictionary>> 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<const Dictionary>(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<AutoSuggestion> AutoCorrector::suggest(const std::wstring &typed, int source,
const std::vector<Layout> &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<AutoSuggestion> candidate;
std::optional<std::wstring> 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<std::wstring> 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<Layout> &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